Compare commits

...
21 Commits
Author SHA1 Message Date
公明andGitHub af4b25b84e Update config.example.yaml 2026-07-23 20:20:41 +08:00
公明andGitHub f0b1955059 Add files via upload 2026-07-23 20:18:55 +08:00
公明andGitHub f5d580bbf0 Add files via upload 2026-07-23 20:17:17 +08:00
公明andGitHub 44d069da2b Add files via upload 2026-07-23 20:14:45 +08:00
公明andGitHub 9297e6e6ee Add files via upload 2026-07-23 20:12:31 +08:00
公明andGitHub 9bbc28c14a Add files via upload 2026-07-23 20:10:57 +08:00
公明andGitHub 4943b9419e Add files via upload 2026-07-23 19:52:14 +08:00
公明andGitHub 446ccd3edb Add files via upload 2026-07-23 19:40:24 +08:00
公明andGitHub a00643b9c0 Add files via upload 2026-07-23 19:39:28 +08:00
公明andGitHub 5c13819f66 Add files via upload 2026-07-23 19:37:45 +08:00
公明andGitHub a7190b8399 Add files via upload 2026-07-23 19:21:50 +08:00
公明andGitHub 6f0eaf7667 Update config.example.yaml 2026-07-23 19:14:49 +08:00
公明andGitHub f285d47404 Add files via upload 2026-07-23 19:14:09 +08:00
公明andGitHub 273d63413b Add files via upload 2026-07-23 19:11:29 +08:00
公明andGitHub 5656315b1e Add files via upload 2026-07-23 19:10:10 +08:00
公明andGitHub cee46f40fa Add files via upload 2026-07-23 19:07:27 +08:00
公明andGitHub 4c55784dbc Add files via upload 2026-07-23 19:04:44 +08:00
公明andGitHub d304ff5c99 Add files via upload 2026-07-23 19:02:37 +08:00
公明andGitHub 0cd4df3518 Add files via upload 2026-07-23 19:00:54 +08:00
公明andGitHub 49333b856c Add files via upload 2026-07-23 18:58:53 +08:00
公明andGitHub a83490f29c Add files via upload 2026-07-23 18:56:18 +08:00
55 changed files with 7320 additions and 928 deletions
+24 -13
View File
@@ -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
View File
@@ -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
View File
@@ -10,7 +10,7 @@
# ============================================ # ============================================
# 前端显示的版本号(可选,不填则显示默认版本) # 前端显示的版本号(可选,不填则显示默认版本)
version: "v1.7.7" version: "v1.7.9"
# 服务器配置 # 服务器配置
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_effortClaude 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 | offoffOpenAI/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_effortClaude 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 | offoffOpenAI/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.provideropenai | claude provider: # 留空则复用默认 AI 通道 provideropenai_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_idworker 继续后台执行,可用 wait_tool_execution 继续等待;0=等到完成 tool_wait_timeout_seconds: 300 # 工具本轮最多等待(秒);到时返回 execution_idworker 继续后台执行,可用 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
+1 -1
View File
@@ -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
+31
View File
@@ -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:
@@ -55,6 +67,25 @@ Streaming endpoints are long-lived. Clients should:
- disable proxy buffering; - disable proxy buffering;
- pass `conversationId` when continuing a conversation. - pass `conversationId` when continuing a conversation.
## File Management Sources
The file management page and `GET /api/chat-uploads` group conversation-related files by source. Directory names still use project IDs or conversation IDs for stability, while the UI prefers project names or conversation titles and keeps the full ID available in tooltips or copied paths.
| Source | `source` | Typical directory | Meaning | Mutability |
| --- | --- | --- | --- | --- |
| Workspace files | `workspace` | `tmp/workspace/projects/<projectId>/...`, `tmp/workspace/conversations/<conversationId>/...` | The Agent workspace for downloaded files, analysis scripts, intermediate results, and generated CSV/XLSX/Markdown files. If an AI-generated file is missing from the UI, check this source first. | Read-only listing; supports copy path, download, and export. |
| Conversation artifacts | `conversation_artifact` | `data/conversation_artifacts/<conversationId>/...` | Conversation-scoped deliverables or archived artifacts such as summaries, reports, or middleware-generated artifacts. | Read-only listing; supports copy path, download, and export. |
| Tool outputs | `reduction` | `tmp/reduction/projects/<projectId>/...`, `tmp/reduction/conversations/<conversationId>/...` | Persisted full tool outputs, scan raw data, or outputs saved before truncation. Useful for reviewing long command or scan results. | Read-only listing; supports copy path, download, and export. |
| Chat uploads | `upload` | `chat_uploads/<date>/<conversationId>/...` | Files manually uploaded in chat or from the file management page. Copy the server absolute path into chat when the AI should reference a file. | Supports upload, mkdir, text edit, rename, delete, copy path, download, and export. |
Related endpoints:
- `GET /api/chat-uploads`: list files filtered by source, project, conversation, or filename.
- `GET /api/chat-uploads/path`: resolve a file-management relative path or internal virtual path to a server absolute path for copy actions.
- `GET /api/chat-uploads/download`: download a file.
- `GET /api/chat-uploads/export`: export the current filtered result as a ZIP.
- `POST /api/chat-uploads`: upload into the chat uploads directory.
## Asset Management and Bulk Import ## Asset Management and Bulk Import
Asset endpoints: Asset endpoints:
+63 -8
View File
@@ -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.
+1 -1
View File
@@ -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:
+2 -2
View File
@@ -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
+3 -2
View File
@@ -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:
+4 -4
View File
@@ -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 工具。
## 路径 ## 路径
+31
View File
@@ -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`
@@ -62,6 +74,25 @@ Content-Type: application/json
- `POST /api/conversations/:id/delete-turn` - `POST /api/conversations/:id/delete-turn`
- `GET /api/messages/:id/process-details` - `GET /api/messages/:id/process-details`
## 文件管理来源
文件管理页面和 `/api/chat-uploads` 列表接口会把对话相关文件按来源归类。底层目录仍使用项目 ID 或会话 ID 保持稳定,界面会优先显示项目名或对话标题,完整 ID 可在提示或路径中查看。
| 来源 | `source` | 典型目录 | 说明 | 可变更性 |
| --- | --- | --- | --- | --- |
| 工作目录 | `workspace` | `tmp/workspace/projects/<projectId>/...``tmp/workspace/conversations/<conversationId>/...` | Agent 执行任务时保存下载文件、分析脚本、中间结果和生成的 CSV/XLSX/Markdown 等。用户反馈“AI 生成的文件找不到”时,通常先看这里。 | 只读展示;支持复制路径、下载、导出。 |
| 会话产物 | `conversation_artifact` | `data/conversation_artifacts/<conversationId>/...` | 系统按会话归档的交付物或会话级产物,例如总结、报告、模型中间件生成的归档内容。 | 只读展示;支持复制路径、下载、导出。 |
| 工具输出 | `reduction` | `tmp/reduction/projects/<projectId>/...``tmp/reduction/conversations/<conversationId>/...` | 超长工具输出、扫描原文或被截断前落盘的结果缓存。适合回看完整工具输出。 | 只读展示;支持复制路径、下载、导出。 |
| 对话附件 | `upload` | `chat_uploads/<date>/<conversationId>/...` | 用户在对话或文件管理页手动上传的附件。需要让 AI 引用某文件时,可复制服务器绝对路径粘贴到对话中。 | 可上传、新建目录、编辑文本文件、重命名、删除、复制路径、下载、导出。 |
相关接口:
- `GET /api/chat-uploads`:按来源、项目、会话、文件名筛选文件。
- `GET /api/chat-uploads/path`:把文件管理中的相对路径或内部虚拟路径解析为服务器绝对路径,用于复制文件或目录路径。
- `GET /api/chat-uploads/download`:下载指定文件。
- `GET /api/chat-uploads/export`:导出当前筛选结果为 ZIP。
- `POST /api/chat-uploads`:上传到对话附件目录。
## 项目、漏洞、攻击链 ## 项目、漏洞、攻击链
项目: 项目:
+50 -18
View File
@@ -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` 留空时可以复用主会话数据库,但独立文件更利于备份。
+1 -1
View File
@@ -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 桥接。
建议目录: 建议目录:
+2 -2
View File
@@ -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 使用更便宜的小模型。
## 推荐审批策略 ## 推荐审批策略
+10 -7
View File
@@ -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
``` ```
## 流式输出中断 ## 流式输出中断
+84 -9
View File
@@ -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)
@@ -1339,6 +1340,7 @@ func setupRoutes(
protected.GET("/chat-uploads", chatUploadsHandler.List) protected.GET("/chat-uploads", chatUploadsHandler.List)
protected.GET("/chat-uploads/export", chatUploadsHandler.Export) protected.GET("/chat-uploads/export", chatUploadsHandler.Export)
protected.GET("/chat-uploads/download", chatUploadsHandler.Download) protected.GET("/chat-uploads/download", chatUploadsHandler.Download)
protected.GET("/chat-uploads/path", chatUploadsHandler.ResolvePath)
protected.GET("/chat-uploads/content", chatUploadsHandler.GetContent) protected.GET("/chat-uploads/content", chatUploadsHandler.GetContent)
protected.POST("/chat-uploads", chatUploadsHandler.Upload) protected.POST("/chat-uploads", chatUploadsHandler.Upload)
protected.POST("/chat-uploads/mkdir", chatUploadsHandler.Mkdir) protected.POST("/chat-uploads/mkdir", chatUploadsHandler.Mkdir)
@@ -1574,22 +1576,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 +1655,11 @@ func registerWebshellManagementTools(mcpServer *mcp.Server, db *database.DB, web
sb.WriteString(fmt.Sprintf(" 类型: %s\n", conn.Type)) sb.WriteString(fmt.Sprintf(" 类型: %s\n", conn.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 +1734,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 +1753,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 +1763,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 +1818,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 +1863,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 +1887,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
+13 -1
View File
@@ -75,6 +75,7 @@ tcp_reverse 默认仅接受 CSB1 加密 BeaconAES-GCM + ImplantToken)才登
"bind_host": map[string]interface{}{"type": "string", "description": "绑定地址,默认 127.0.0.1;外网监听常用 0.0.0.0"}, "bind_host": map[string]interface{}{"type": "string", "description": "绑定地址,默认 127.0.0.1;外网监听常用 0.0.0.0"},
"callback_host": map[string]interface{}{"type": "string", "description": "可选:植入端/Payload 回连主机名(公网 IP 或域名)。写入 config_json;生成 oneliner/beacon 时优先于 bind_host。update 时传入空字符串可清除"}, "callback_host": map[string]interface{}{"type": "string", "description": "可选:植入端/Payload 回连主机名(公网 IP 或域名)。写入 config_json;生成 oneliner/beacon 时优先于 bind_host。update 时传入空字符串可清除"},
"bind_port": map[string]interface{}{"type": "integer", "description": fmt.Sprintf("绑定端口(create 必填)。须 ≠ %d(当前本服务 Web/API 端口,配置 server.port", webListenPort), "minimum": 1, "maximum": 65535}, "bind_port": map[string]interface{}{"type": "integer", "description": fmt.Sprintf("绑定端口(create 必填)。须 ≠ %d(当前本服务 Web/API 端口,配置 server.port", webListenPort), "minimum": 1, "maximum": 65535},
"project_id": map[string]interface{}{"type": "string", "description": "所属项目 ID。create 省略时默认使用当前对话绑定项目;未绑定项目的对话则创建未绑定监听器"},
"profile_id": map[string]interface{}{"type": "string", "description": "Malleable Profile ID"}, "profile_id": map[string]interface{}{"type": "string", "description": "Malleable Profile ID"},
"remark": map[string]interface{}{"type": "string", "description": "备注"}, "remark": map[string]interface{}{"type": "string", "description": "备注"},
"config": map[string]interface{}{"type": "object", "description": "高级配置(beacon 路径/TLS/OPSEC 等),create/update 可用。tcp_reverse 可选 allow_legacy_shell:true 允许未加密经典 shell(默认 false"}, "config": map[string]interface{}{"type": "object", "description": "高级配置(beacon 路径/TLS/OPSEC 等),create/update 可用。tcp_reverse 可选 allow_legacy_shell:true 允许未加密经典 shell(默认 false"},
@@ -87,7 +88,7 @@ tcp_reverse 默认仅接受 CSB1 加密 BeaconAES-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)
} }
@@ -116,6 +117,13 @@ tcp_reverse 默认仅接受 CSB1 加密 BeaconAES-GCM + ImplantToken)才登
cfg = &c2.ListenerConfig{} cfg = &c2.ListenerConfig{}
_ = json.Unmarshal(cfgBytes, cfg) _ = json.Unmarshal(cfgBytes, cfg)
} }
projectID := strings.TrimSpace(getString(params, "project_id"))
if projectID == "" {
projectID = mcpEffectiveProjectFilter(ctx, m.DB())
if projectID == database.ProjectFilterUnbound {
projectID = ""
}
}
input := c2.CreateListenerInput{ input := c2.CreateListenerInput{
Name: getString(params, "name"), Name: getString(params, "name"),
Type: getString(params, "type"), Type: getString(params, "type"),
@@ -123,6 +131,7 @@ tcp_reverse 默认仅接受 CSB1 加密 BeaconAES-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: projectID,
Config: cfg, Config: cfg,
CallbackHost: getString(params, "callback_host"), CallbackHost: getString(params, "callback_host"),
} }
@@ -260,6 +269,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 +505,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 +656,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")),
+69
View File
@@ -0,0 +1,69 @@
package app
import (
"context"
"path/filepath"
"testing"
"cyberstrike-ai/internal/authctx"
"cyberstrike-ai/internal/c2"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/mcp"
"cyberstrike-ai/internal/mcp/builtin"
"go.uber.org/zap"
)
func TestC2ListenerCreateInheritsConversationProject(t *testing.T) {
db, err := database.NewDB(filepath.Join(t.TempDir(), "c2-tools.db"), zap.NewNop())
if err != nil {
t.Fatal(err)
}
defer db.Close()
user, err := db.CreateRBACUser("c2-agent", "C2 Agent", "hash", true, nil)
if err != nil {
t.Fatal(err)
}
project, err := db.CreateProject(&database.Project{Name: "engagement"})
if err != nil {
t.Fatal(err)
}
if err := db.AssignResourceToUser(user.ID, "project", project.ID); err != nil {
t.Fatal(err)
}
conversation, err := db.CreateConversation("project chat", database.ConversationCreateMeta{ProjectID: project.ID})
if err != nil {
t.Fatal(err)
}
principal := authctx.NewPrincipal(user.ID, user.Username, database.RBACScopeAssigned, map[string]bool{
"c2:read": true, "c2:write": true,
})
ctx := authctx.WithPrincipal(mcp.WithMCPConversationID(context.Background(), conversation.ID), principal)
server := mcp.NewServer(zap.NewNop())
server.SetToolAuthorizer(mcpToolAuthorizer(db))
registerC2Tools(server, c2.NewManager(db, zap.NewNop(), t.TempDir()), zap.NewNop(), 8080)
result, _, err := server.CallTool(ctx, builtin.ToolC2Listener, map[string]interface{}{
"action": "create",
"name": "tcp-reverse-2222",
"type": "tcp_reverse",
"bind_host": "0.0.0.0",
"bind_port": 2222,
})
if err != nil || result == nil || result.IsError {
t.Fatalf("create listener result=%#v err=%v text=%q", result, err, toolResultText(result))
}
listeners, err := db.ListC2Listeners()
if err != nil {
t.Fatal(err)
}
if len(listeners) != 1 {
t.Fatalf("listener count=%d, want 1", len(listeners))
}
if listeners[0].ProjectID != project.ID {
t.Fatalf("listener project_id=%q, want %q", listeners[0].ProjectID, project.ID)
}
}
+115 -6
View File
@@ -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,11 +237,27 @@ func authorizeC2Action(principal authctx.Principal, db *database.DB, args map[st
if db == nil || !db.UserCanAccessResource(principal.UserID, principal.ScopeFor(permission), resourceType, candidate) { 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
} }
if id == "" { if id == "" {
if action == "create" || action == "list" { if action == "create" {
projectID := mcpAuthorizationString(args, "project_id")
if projectID == "" {
projectID = mcpEffectiveProjectFilter(ctx, db)
if projectID == database.ProjectFilterUnbound {
projectID = ""
}
}
if projectID != "" && (db == nil || !db.UserCanAccessResource(principal.UserID, principal.ScopeFor(permission), "project", projectID)) {
return fmt.Errorf("no access to project %s", projectID)
}
return nil
}
if action == "list" {
return nil return nil
} }
return fmt.Errorf("missing resource identifier %s", argument) return fmt.Errorf("missing resource identifier %s", argument)
@@ -240,9 +265,93 @@ func authorizeC2Action(principal authctx.Principal, db *database.DB, args map[st
if db == nil || !db.UserCanAccessResource(principal.UserID, principal.ScopeFor(permission), resourceType, id) { 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) {
+85
View File
@@ -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 {
+26
View File
@@ -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 ""
}
+5 -3
View File
@@ -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
View File
@@ -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 分钟,避免异常长时间占用
+42
View File
@@ -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
View File
@@ -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)
+5
View File
@@ -835,6 +835,11 @@ func (db *DB) ConversationArtifactsBaseDir() string {
return strings.TrimSpace(db.conversationArtifactsDir) return strings.TrimSpace(db.conversationArtifactsDir)
} }
// EinoWorkspaceBaseDir returns the configured agent workspace root.
func (db *DB) EinoWorkspaceBaseDir() string {
return db.einoWorkspaceBaseDir()
}
func (db *DB) einoWorkspaceBaseDir() string { func (db *DB) einoWorkspaceBaseDir() string {
if db == nil { if db == nil {
return "" return ""
+12
View File
@@ -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")
+20 -1
View File
@@ -111,6 +111,19 @@ func (db *DB) GetProject(id string) (*Project, error) {
return &p, nil return &p, nil
} }
// GetProjectName returns a project display name without loading the full record.
func (db *DB) GetProjectName(id string) (string, error) {
var name string
err := db.QueryRow(`SELECT name FROM projects WHERE id = ?`, id).Scan(&name)
if err != nil {
if err == sql.ErrNoRows {
return "", fmt.Errorf("项目不存在")
}
return "", fmt.Errorf("获取项目名称失败: %w", err)
}
return strings.TrimSpace(name), nil
}
func projectListSearchPattern(q string) string { func projectListSearchPattern(q string) string {
q = strings.TrimSpace(q) q = strings.TrimSpace(q)
if q == "" { if q == "" {
@@ -263,7 +276,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 +284,12 @@ func (db *DB) DeleteProject(id string) error {
if _, err := db.Exec(`UPDATE assets SET project_id = NULL WHERE project_id = ?`, id); err != nil { 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)
+117 -11
View File
@@ -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")
+19 -11
View File
@@ -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
+14
View File
@@ -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/streamdeep | plan_execute | supervisor;空则等同 deep。机器人/批量等无请求体时由服务端默认 deep。/api/eino-agent* 不使用此字段。 // Orchestration 仅对 /api/multi-agent、/api/multi-agent/streamdeep | 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
View File
@@ -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 {
+347 -41
View File
@@ -27,11 +27,14 @@ import (
const ( const (
chatUploadsRootDirName = "chat_uploads" chatUploadsRootDirName = "chat_uploads"
reductionRootDirName = "tmp/reduction" reductionRootDirName = "tmp/reduction"
workspaceRootDirName = "tmp/workspace"
artifactsRootDirName = "data/conversation_artifacts" artifactsRootDirName = "data/conversation_artifacts"
reductionVirtualPrefix = "__reduction__/" reductionVirtualPrefix = "__reduction__/"
workspaceVirtualPrefix = "__workspace__/"
artifactVirtualPrefix = "__conversation_artifact__/" artifactVirtualPrefix = "__conversation_artifact__/"
chatUploadSourceUpload = "upload" chatUploadSourceUpload = "upload"
chatUploadSourceReduction = "reduction" chatUploadSourceReduction = "reduction"
chatUploadSourceWorkspace = "workspace"
chatUploadSourceConversation = "conversation_artifact" chatUploadSourceConversation = "conversation_artifact"
maxChatUploadEditBytes = 2 * 1024 * 1024 // 文本编辑上限 maxChatUploadEditBytes = 2 * 1024 * 1024 // 文本编辑上限
) )
@@ -104,6 +107,11 @@ func (h *ChatUploadsHandler) reductionPathAllowed(c *gin.Context, scope, id stri
func (h *ChatUploadsHandler) reductionVirtualPathAllowed(c *gin.Context, relativePath string) bool { func (h *ChatUploadsHandler) reductionVirtualPathAllowed(c *gin.Context, relativePath string) bool {
rel := strings.TrimPrefix(strings.TrimSpace(relativePath), reductionVirtualPrefix) rel := strings.TrimPrefix(strings.TrimSpace(relativePath), reductionVirtualPrefix)
rel = strings.Trim(filepath.ToSlash(filepath.Clean(filepath.FromSlash(rel))), "/")
if rel == "projects" || rel == "conversations" {
_, ok := security.CurrentSession(c)
return ok
}
parts := strings.Split(filepath.ToSlash(rel), "/") parts := strings.Split(filepath.ToSlash(rel), "/")
if len(parts) < 2 { if len(parts) < 2 {
return false return false
@@ -111,6 +119,24 @@ func (h *ChatUploadsHandler) reductionVirtualPathAllowed(c *gin.Context, relativ
return h.reductionPathAllowed(c, parts[0], parts[1]) return h.reductionPathAllowed(c, parts[0], parts[1])
} }
func (h *ChatUploadsHandler) workspacePathAllowed(c *gin.Context, scope, id string) bool {
return h.reductionPathAllowed(c, scope, id)
}
func (h *ChatUploadsHandler) workspaceVirtualPathAllowed(c *gin.Context, relativePath string) bool {
rel := strings.TrimPrefix(strings.TrimSpace(relativePath), workspaceVirtualPrefix)
rel = strings.Trim(filepath.ToSlash(filepath.Clean(filepath.FromSlash(rel))), "/")
if rel == "projects" || rel == "conversations" {
_, ok := security.CurrentSession(c)
return ok
}
parts := strings.Split(filepath.ToSlash(rel), "/")
if len(parts) < 2 {
return false
}
return h.workspacePathAllowed(c, parts[0], parts[1])
}
func (h *ChatUploadsHandler) conversationArtifactPathAllowed(c *gin.Context, conversationID string) bool { func (h *ChatUploadsHandler) conversationArtifactPathAllowed(c *gin.Context, conversationID string) bool {
session, ok := security.CurrentSession(c) session, ok := security.CurrentSession(c)
if !ok || h.db == nil { if !ok || h.db == nil {
@@ -163,6 +189,26 @@ func (h *ChatUploadsHandler) absReductionRoot() (string, error) {
return filepath.Abs(filepath.Join(cwd, reductionRootDirName)) return filepath.Abs(filepath.Join(cwd, reductionRootDirName))
} }
func (h *ChatUploadsHandler) absWorkspaceRoot() (string, error) {
if h.db != nil {
if base := strings.TrimSpace(h.db.EinoWorkspaceBaseDir()); base != "" {
if filepath.IsAbs(base) {
return filepath.Abs(base)
}
cwd, err := os.Getwd()
if err != nil {
return "", err
}
return filepath.Abs(filepath.Join(cwd, base))
}
}
cwd, err := os.Getwd()
if err != nil {
return "", err
}
return filepath.Abs(filepath.Join(cwd, workspaceRootDirName))
}
func (h *ChatUploadsHandler) absConversationArtifactsRoot() (string, error) { func (h *ChatUploadsHandler) absConversationArtifactsRoot() (string, error) {
if h.db != nil { if h.db != nil {
if base := strings.TrimSpace(h.db.ConversationArtifactsBaseDir()); base != "" { if base := strings.TrimSpace(h.db.ConversationArtifactsBaseDir()); base != "" {
@@ -211,15 +257,17 @@ func (h *ChatUploadsHandler) resolveUnderChatUploads(relativePath string) (abs s
// ChatUploadFileItem 列表项 // ChatUploadFileItem 列表项
type ChatUploadFileItem struct { type ChatUploadFileItem struct {
RelativePath string `json:"relativePath"` RelativePath string `json:"relativePath"`
AbsolutePath string `json:"absolutePath"` // 服务器上的绝对路径,便于在对话中引用(与附件落盘路径一致) AbsolutePath string `json:"absolutePath"` // 服务器上的绝对路径,便于在对话中引用(与附件落盘路径一致)
Name string `json:"name"` Name string `json:"name"`
Source string `json:"source,omitempty"` Source string `json:"source,omitempty"`
Size int64 `json:"size"` Size int64 `json:"size"`
ModifiedUnix int64 `json:"modifiedUnix"` ModifiedUnix int64 `json:"modifiedUnix"`
Date string `json:"date"` Date string `json:"date"`
ConversationID string `json:"conversationId"` ConversationID string `json:"conversationId"`
ProjectID string `json:"projectId,omitempty"` ConversationTitle string `json:"conversationTitle,omitempty"`
ProjectID string `json:"projectId,omitempty"`
ProjectName string `json:"projectName,omitempty"`
// SubPath 为日期、会话目录之下的子路径(不含文件名),如 date/conv/a/b/file 则为 "a/b";无嵌套则为 ""。 // SubPath 为日期、会话目录之下的子路径(不含文件名),如 date/conv/a/b/file 则为 "a/b";无嵌套则为 ""。
SubPath string `json:"subPath"` SubPath string `json:"subPath"`
} }
@@ -240,6 +288,38 @@ func (h *ChatUploadsHandler) conversationProjectID(conversationID string, cache
return projectID return projectID
} }
func (h *ChatUploadsHandler) conversationTitle(conversationID string, cache map[string]string) string {
conversationID = strings.TrimSpace(conversationID)
if conversationID == "" || conversationID == "_manual" || conversationID == "_new" || h.db == nil {
return ""
}
if v, ok := cache[conversationID]; ok {
return v
}
title, err := h.db.GetConversationTitle(conversationID)
if err != nil {
title = ""
}
cache[conversationID] = title
return title
}
func (h *ChatUploadsHandler) projectName(projectID string, cache map[string]string) string {
projectID = strings.TrimSpace(projectID)
if projectID == "" || h.db == nil {
return ""
}
if v, ok := cache[projectID]; ok {
return v
}
name, err := h.db.GetProjectName(projectID)
if err != nil {
name = ""
}
cache[projectID] = name
return name
}
func (h *ChatUploadsHandler) collectFiles(c *gin.Context, conversationFilter, projectFilter string) ([]ChatUploadFileItem, []string, error) { func (h *ChatUploadsHandler) collectFiles(c *gin.Context, conversationFilter, projectFilter string) ([]ChatUploadFileItem, []string, error) {
root, err := h.absRoot() root, err := h.absRoot()
if err != nil { if err != nil {
@@ -252,6 +332,8 @@ func (h *ChatUploadsHandler) collectFiles(c *gin.Context, conversationFilter, pr
var files []ChatUploadFileItem var files []ChatUploadFileItem
var folders []string var folders []string
projectCache := make(map[string]string) projectCache := make(map[string]string)
projectNameCache := make(map[string]string)
conversationTitleCache := make(map[string]string)
err = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error { err = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
if walkErr != nil { if walkErr != nil {
return walkErr return walkErr
@@ -293,16 +375,18 @@ func (h *ChatUploadsHandler) collectFiles(c *gin.Context, conversationFilter, pr
} }
absPath, _ := filepath.Abs(path) absPath, _ := filepath.Abs(path)
files = append(files, ChatUploadFileItem{ files = append(files, ChatUploadFileItem{
RelativePath: relSlash, RelativePath: relSlash,
AbsolutePath: absPath, AbsolutePath: absPath,
Name: d.Name(), Name: d.Name(),
Source: chatUploadSourceUpload, Source: chatUploadSourceUpload,
Size: info.Size(), Size: info.Size(),
ModifiedUnix: info.ModTime().Unix(), ModifiedUnix: info.ModTime().Unix(),
Date: dateStr, Date: dateStr,
ConversationID: convID, ConversationID: convID,
ProjectID: projectID, ConversationTitle: h.conversationTitle(convID, conversationTitleCache),
SubPath: subPath, ProjectID: projectID,
ProjectName: h.projectName(projectID, projectNameCache),
SubPath: subPath,
}) })
return nil return nil
}) })
@@ -354,6 +438,12 @@ func (h *ChatUploadsHandler) collectFiles(c *gin.Context, conversationFilter, pr
} else if len(reductionFiles) > 0 { } else if len(reductionFiles) > 0 {
files = append(files, reductionFiles...) files = append(files, reductionFiles...)
} }
workspaceFiles, err := h.collectWorkspaceFiles(c, conversationFilter, projectFilter)
if err != nil {
h.logger.Warn("列举 workspace 产物失败", zap.Error(err))
} else if len(workspaceFiles) > 0 {
files = append(files, workspaceFiles...)
}
artifactFiles, err := h.collectConversationArtifactFiles(c, conversationFilter, projectFilter) artifactFiles, err := h.collectConversationArtifactFiles(c, conversationFilter, projectFilter)
if err != nil { if err != nil {
h.logger.Warn("列举 conversation_artifacts 产物失败", zap.Error(err)) h.logger.Warn("列举 conversation_artifacts 产物失败", zap.Error(err))
@@ -375,6 +465,8 @@ func (h *ChatUploadsHandler) collectReductionFiles(c *gin.Context, conversationF
return nil, nil return nil, nil
} }
projectCache := make(map[string]string) projectCache := make(map[string]string)
projectNameCache := make(map[string]string)
conversationTitleCache := make(map[string]string)
files := make([]ChatUploadFileItem, 0) files := make([]ChatUploadFileItem, 0)
for _, scope := range []string{"conversations", "projects"} { for _, scope := range []string{"conversations", "projects"} {
scopeRoot := filepath.Join(root, scope) scopeRoot := filepath.Join(root, scope)
@@ -403,7 +495,10 @@ func (h *ChatUploadsHandler) collectReductionFiles(c *gin.Context, conversationF
projectID = ownerID projectID = ownerID
} }
if conversationFilter != "" && convID != conversationFilter { if conversationFilter != "" && convID != conversationFilter {
return nil if scope != "projects" || h.conversationProjectID(conversationFilter, projectCache) != projectID {
return nil
}
convID = conversationFilter
} }
if projectFilter != "" && projectID != projectFilter { if projectFilter != "" && projectID != projectFilter {
return nil return nil
@@ -418,16 +513,90 @@ func (h *ChatUploadsHandler) collectReductionFiles(c *gin.Context, conversationF
} }
abs, _ := filepath.Abs(path) abs, _ := filepath.Abs(path)
files = append(files, ChatUploadFileItem{ files = append(files, ChatUploadFileItem{
RelativePath: reductionVirtualPrefix + relSlash, RelativePath: reductionVirtualPrefix + relSlash,
AbsolutePath: abs, AbsolutePath: abs,
Name: name, Name: name,
Source: chatUploadSourceReduction, Source: chatUploadSourceReduction,
Size: info.Size(), Size: info.Size(),
ModifiedUnix: info.ModTime().Unix(), ModifiedUnix: info.ModTime().Unix(),
Date: info.ModTime().Format("2006-01-02"), Date: info.ModTime().Format("2006-01-02"),
ConversationID: convID, ConversationID: convID,
ProjectID: projectID, ConversationTitle: h.conversationTitle(convID, conversationTitleCache),
SubPath: strings.Join(parts[2:len(parts)-1], "/"), ProjectID: projectID,
ProjectName: h.projectName(projectID, projectNameCache),
SubPath: strings.Join(parts[2:len(parts)-1], "/"),
})
return nil
})
}
return files, nil
}
func (h *ChatUploadsHandler) collectWorkspaceFiles(c *gin.Context, conversationFilter, projectFilter string) ([]ChatUploadFileItem, error) {
root, err := h.absWorkspaceRoot()
if err != nil {
return nil, err
}
if st, err := os.Stat(root); err != nil || !st.IsDir() {
return nil, nil
}
projectCache := make(map[string]string)
projectNameCache := make(map[string]string)
conversationTitleCache := make(map[string]string)
files := make([]ChatUploadFileItem, 0)
for _, scope := range []string{"conversations", "projects"} {
scopeRoot := filepath.Join(root, scope)
_ = filepath.WalkDir(scopeRoot, func(path string, d os.DirEntry, walkErr error) error {
if walkErr != nil || d == nil || d.IsDir() {
return nil
}
rel, err := filepath.Rel(root, path)
if err != nil {
return nil
}
relSlash := filepath.ToSlash(rel)
parts := strings.Split(relSlash, "/")
if len(parts) < 3 {
return nil
}
ownerID := parts[1]
if !h.workspacePathAllowed(c, scope, ownerID) {
return nil
}
var convID, projectID string
if scope == "conversations" {
convID = ownerID
projectID = h.conversationProjectID(convID, projectCache)
} else {
projectID = ownerID
}
if conversationFilter != "" && convID != conversationFilter {
if scope != "projects" || h.conversationProjectID(conversationFilter, projectCache) != projectID {
return nil
}
convID = conversationFilter
}
if projectFilter != "" && projectID != projectFilter {
return nil
}
info, err := d.Info()
if err != nil {
return nil
}
abs, _ := filepath.Abs(path)
files = append(files, ChatUploadFileItem{
RelativePath: workspaceVirtualPrefix + relSlash,
AbsolutePath: abs,
Name: d.Name(),
Source: chatUploadSourceWorkspace,
Size: info.Size(),
ModifiedUnix: info.ModTime().Unix(),
Date: info.ModTime().Format("2006-01-02"),
ConversationID: convID,
ConversationTitle: h.conversationTitle(convID, conversationTitleCache),
ProjectID: projectID,
ProjectName: h.projectName(projectID, projectNameCache),
SubPath: strings.Join(parts[2:len(parts)-1], "/"),
}) })
return nil return nil
}) })
@@ -444,6 +613,8 @@ func (h *ChatUploadsHandler) collectConversationArtifactFiles(c *gin.Context, co
return nil, nil return nil, nil
} }
projectCache := make(map[string]string) projectCache := make(map[string]string)
projectNameCache := make(map[string]string)
conversationTitleCache := make(map[string]string)
files := make([]ChatUploadFileItem, 0) files := make([]ChatUploadFileItem, 0)
_ = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error { _ = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
if walkErr != nil || d == nil || d.IsDir() { if walkErr != nil || d == nil || d.IsDir() {
@@ -479,16 +650,18 @@ func (h *ChatUploadsHandler) collectConversationArtifactFiles(c *gin.Context, co
} }
abs, _ := filepath.Abs(path) abs, _ := filepath.Abs(path)
files = append(files, ChatUploadFileItem{ files = append(files, ChatUploadFileItem{
RelativePath: artifactVirtualPrefix + relSlash, RelativePath: artifactVirtualPrefix + relSlash,
AbsolutePath: abs, AbsolutePath: abs,
Name: name, Name: name,
Source: chatUploadSourceConversation, Source: chatUploadSourceConversation,
Size: info.Size(), Size: info.Size(),
ModifiedUnix: info.ModTime().Unix(), ModifiedUnix: info.ModTime().Unix(),
Date: info.ModTime().Format("2006-01-02"), Date: info.ModTime().Format("2006-01-02"),
ConversationID: convID, ConversationID: convID,
ProjectID: projectID, ConversationTitle: h.conversationTitle(convID, conversationTitleCache),
SubPath: strings.Join(parts[1:len(parts)-1], "/"), ProjectID: projectID,
ProjectName: h.projectName(projectID, projectNameCache),
SubPath: strings.Join(parts[1:len(parts)-1], "/"),
}) })
return nil return nil
}) })
@@ -516,6 +689,27 @@ func (h *ChatUploadsHandler) resolveReductionVirtualPath(relativePath string) (s
return full, nil return full, nil
} }
func (h *ChatUploadsHandler) resolveWorkspaceVirtualPath(relativePath string) (string, error) {
rel := strings.TrimPrefix(strings.TrimSpace(relativePath), workspaceVirtualPrefix)
rel = filepath.Clean(filepath.FromSlash(rel))
if rel == "." || strings.HasPrefix(rel, "..") {
return "", fmt.Errorf("invalid path")
}
root, err := h.absWorkspaceRoot()
if err != nil {
return "", err
}
full, err := filepath.Abs(filepath.Join(root, rel))
if err != nil {
return "", err
}
rootAbs, _ := filepath.Abs(root)
if full != rootAbs && !strings.HasPrefix(full, rootAbs+string(filepath.Separator)) {
return "", fmt.Errorf("path escapes workspace root")
}
return full, nil
}
func (h *ChatUploadsHandler) resolveConversationArtifactVirtualPath(relativePath string) (string, error) { func (h *ChatUploadsHandler) resolveConversationArtifactVirtualPath(relativePath string) (string, error) {
rel := strings.TrimPrefix(strings.TrimSpace(relativePath), artifactVirtualPrefix) rel := strings.TrimPrefix(strings.TrimSpace(relativePath), artifactVirtualPrefix)
rel = filepath.Clean(filepath.FromSlash(rel)) rel = filepath.Clean(filepath.FromSlash(rel))
@@ -539,8 +733,10 @@ func (h *ChatUploadsHandler) resolveConversationArtifactVirtualPath(relativePath
func chatUploadItemIsInternal(item ChatUploadFileItem) bool { func chatUploadItemIsInternal(item ChatUploadFileItem) bool {
return item.Source == chatUploadSourceReduction || return item.Source == chatUploadSourceReduction ||
item.Source == chatUploadSourceWorkspace ||
item.Source == chatUploadSourceConversation || item.Source == chatUploadSourceConversation ||
strings.HasPrefix(item.RelativePath, reductionVirtualPrefix) || strings.HasPrefix(item.RelativePath, reductionVirtualPrefix) ||
strings.HasPrefix(item.RelativePath, workspaceVirtualPrefix) ||
strings.HasPrefix(item.RelativePath, artifactVirtualPrefix) strings.HasPrefix(item.RelativePath, artifactVirtualPrefix)
} }
@@ -548,6 +744,8 @@ func (h *ChatUploadsHandler) resolveListedFilePath(item ChatUploadFileItem) (str
switch { switch {
case item.Source == chatUploadSourceReduction || strings.HasPrefix(item.RelativePath, reductionVirtualPrefix): case item.Source == chatUploadSourceReduction || strings.HasPrefix(item.RelativePath, reductionVirtualPrefix):
return h.resolveReductionVirtualPath(item.RelativePath) return h.resolveReductionVirtualPath(item.RelativePath)
case item.Source == chatUploadSourceWorkspace || strings.HasPrefix(item.RelativePath, workspaceVirtualPrefix):
return h.resolveWorkspaceVirtualPath(item.RelativePath)
case item.Source == chatUploadSourceConversation || strings.HasPrefix(item.RelativePath, artifactVirtualPrefix): case item.Source == chatUploadSourceConversation || strings.HasPrefix(item.RelativePath, artifactVirtualPrefix):
return h.resolveConversationArtifactVirtualPath(item.RelativePath) return h.resolveConversationArtifactVirtualPath(item.RelativePath)
default: default:
@@ -705,7 +903,7 @@ func (h *ChatUploadsHandler) Export(c *gin.Context) {
"fileCount": len(files), "fileCount": len(files),
"files": files, "files": files,
"layout": "chat uploads are stored under conversations/<conversationId>/; internal outputs are stored under internal/<source>/", "layout": "chat uploads are stored under conversations/<conversationId>/; internal outputs are stored under internal/<source>/",
"sourceDirectory": []string{chatUploadsRootDirName, reductionRootDirName, artifactsRootDirName}, "sourceDirectory": []string{chatUploadsRootDirName, reductionRootDirName, workspaceRootDirName, artifactsRootDirName},
} }
manifestBytes, _ := json.MarshalIndent(manifest, "", " ") manifestBytes, _ := json.MarshalIndent(manifest, "", " ")
mw, err := zw.Create("manifest.json") mw, err := zw.Create("manifest.json")
@@ -732,6 +930,9 @@ func (h *ChatUploadsHandler) Export(c *gin.Context) {
if filepath.Ext(zipName) == "" { if filepath.Ext(zipName) == "" {
zipName += ".txt" zipName += ".txt"
} }
} else if item.Source == chatUploadSourceWorkspace || strings.HasPrefix(item.RelativePath, workspaceVirtualPrefix) {
rel := strings.TrimPrefix(item.RelativePath, workspaceVirtualPrefix)
zipName = filepath.ToSlash(filepath.Join("internal", "workspace", rel))
} else if item.Source == chatUploadSourceConversation || strings.HasPrefix(item.RelativePath, artifactVirtualPrefix) { } else if item.Source == chatUploadSourceConversation || strings.HasPrefix(item.RelativePath, artifactVirtualPrefix) {
rel := strings.TrimPrefix(item.RelativePath, artifactVirtualPrefix) rel := strings.TrimPrefix(item.RelativePath, artifactVirtualPrefix)
zipName = filepath.ToSlash(filepath.Join("internal", "conversation_artifacts", rel)) zipName = filepath.ToSlash(filepath.Join("internal", "conversation_artifacts", rel))
@@ -800,6 +1001,24 @@ func (h *ChatUploadsHandler) Download(c *gin.Context) {
c.FileAttachment(abs, name) c.FileAttachment(abs, name)
return return
} }
if strings.HasPrefix(strings.TrimSpace(p), workspaceVirtualPrefix) {
if !h.workspaceVirtualPathAllowed(c, p) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
abs, err := h.resolveWorkspaceVirtualPath(p)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
st, err := os.Stat(abs)
if err != nil || st.IsDir() {
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
return
}
c.FileAttachment(abs, filepath.Base(abs))
return
}
if strings.HasPrefix(strings.TrimSpace(p), artifactVirtualPrefix) { if strings.HasPrefix(strings.TrimSpace(p), artifactVirtualPrefix) {
if !h.conversationArtifactVirtualPathAllowed(c, p) { if !h.conversationArtifactVirtualPathAllowed(c, p) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"}) c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
@@ -839,6 +1058,93 @@ func (h *ChatUploadsHandler) Download(c *gin.Context) {
c.FileAttachment(abs, filepath.Base(abs)) c.FileAttachment(abs, filepath.Base(abs))
} }
// ResolvePath GET /api/chat-uploads/path?path=...&kind=file|directory
func (h *ChatUploadsHandler) ResolvePath(c *gin.Context) {
p := strings.TrimSpace(c.Query("path"))
kind := strings.TrimSpace(c.Query("kind"))
if kind == "" {
kind = "file"
}
var abs string
var err error
switch {
case strings.HasPrefix(p, reductionVirtualPrefix):
if strings.Trim(strings.TrimPrefix(p, reductionVirtualPrefix), "/") == "" {
if _, ok := security.CurrentSession(c); !ok {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
abs, err = h.absReductionRoot()
break
}
if !h.reductionVirtualPathAllowed(c, p) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
abs, err = h.resolveReductionVirtualPath(p)
case strings.HasPrefix(p, workspaceVirtualPrefix):
if strings.Trim(strings.TrimPrefix(p, workspaceVirtualPrefix), "/") == "" {
if _, ok := security.CurrentSession(c); !ok {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
abs, err = h.absWorkspaceRoot()
break
}
if !h.workspaceVirtualPathAllowed(c, p) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
abs, err = h.resolveWorkspaceVirtualPath(p)
case strings.HasPrefix(p, artifactVirtualPrefix):
if strings.Trim(strings.TrimPrefix(p, artifactVirtualPrefix), "/") == "" {
if _, ok := security.CurrentSession(c); !ok {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
abs, err = h.absConversationArtifactsRoot()
break
}
if !h.conversationArtifactVirtualPathAllowed(c, p) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
abs, err = h.resolveConversationArtifactVirtualPath(p)
default:
if p == "" || p == "." {
if _, ok := security.CurrentSession(c); !ok {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
abs, err = h.absRoot()
break
}
if !h.pathAllowed(c, p) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
abs, err = h.resolveUnderChatUploads(p)
}
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
st, err := os.Stat(abs)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "path not found"})
return
}
if kind == "directory" && !st.IsDir() {
c.JSON(http.StatusBadRequest, gin.H{"error": "not a directory"})
return
}
if kind != "directory" && st.IsDir() {
c.JSON(http.StatusBadRequest, gin.H{"error": "not a file"})
return
}
c.JSON(http.StatusOK, gin.H{"absolutePath": abs, "isDir": st.IsDir()})
}
type chatUploadPathBody struct { type chatUploadPathBody struct {
Path string `json:"path"` Path string `json:"path"`
} }
+80 -1
View File
@@ -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")
+18 -4
View File
@@ -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,
+15 -4
View File
@@ -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,
+45 -12
View File
@@ -5803,7 +5803,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"parameters": []map[string]interface{}{ "parameters": []map[string]interface{}{
{"name": "conversation", "in": "query", "required": false, "description": "按对话ID过滤", "schema": map[string]interface{}{"type": "string"}}, {"name": "conversation", "in": "query", "required": false, "description": "按对话ID过滤", "schema": map[string]interface{}{"type": "string"}},
{"name": "project", "in": "query", "required": false, "description": "按项目ID过滤", "schema": map[string]interface{}{"type": "string"}}, {"name": "project", "in": "query", "required": false, "description": "按项目ID过滤", "schema": map[string]interface{}{"type": "string"}},
{"name": "source", "in": "query", "required": false, "description": "按来源过滤:upload/reduction/conversation_artifact/all", "schema": map[string]interface{}{"type": "string", "enum": []string{"all", "upload", "reduction", "conversation_artifact"}}}, {"name": "source", "in": "query", "required": false, "description": "按来源过滤:upload/reduction/workspace/conversation_artifact/all", "schema": map[string]interface{}{"type": "string", "enum": []string{"all", "upload", "reduction", "workspace", "conversation_artifact"}}},
{"name": "search", "in": "query", "required": false, "description": "按文件名或子路径搜索", "schema": map[string]interface{}{"type": "string"}}, {"name": "search", "in": "query", "required": false, "description": "按文件名或子路径搜索", "schema": map[string]interface{}{"type": "string"}},
{"name": "page", "in": "query", "required": false, "description": "页码,从1开始", "schema": map[string]interface{}{"type": "integer", "default": 1}}, {"name": "page", "in": "query", "required": false, "description": "页码,从1开始", "schema": map[string]interface{}{"type": "integer", "default": 1}},
{"name": "pageSize", "in": "query", "required": false, "description": "每页数量,传 all 返回全部", "schema": map[string]interface{}{"oneOf": []map[string]interface{}{{"type": "integer"}, {"type": "string", "enum": []string{"all"}}}}}, {"name": "pageSize", "in": "query", "required": false, "description": "每页数量,传 all 返回全部", "schema": map[string]interface{}{"oneOf": []map[string]interface{}{{"type": "integer"}, {"type": "string", "enum": []string{"all"}}}}},
@@ -5821,16 +5821,18 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"items": map[string]interface{}{ "items": map[string]interface{}{
"type": "object", "type": "object",
"properties": map[string]interface{}{ "properties": map[string]interface{}{
"relativePath": map[string]interface{}{"type": "string"}, "relativePath": map[string]interface{}{"type": "string"},
"absolutePath": map[string]interface{}{"type": "string"}, "absolutePath": map[string]interface{}{"type": "string"},
"name": map[string]interface{}{"type": "string"}, "name": map[string]interface{}{"type": "string"},
"size": map[string]interface{}{"type": "integer"}, "size": map[string]interface{}{"type": "integer"},
"modifiedUnix": map[string]interface{}{"type": "integer"}, "modifiedUnix": map[string]interface{}{"type": "integer"},
"date": map[string]interface{}{"type": "string"}, "date": map[string]interface{}{"type": "string"},
"conversationId": map[string]interface{}{"type": "string"}, "conversationId": map[string]interface{}{"type": "string"},
"projectId": map[string]interface{}{"type": "string"}, "conversationTitle": map[string]interface{}{"type": "string"},
"subPath": map[string]interface{}{"type": "string"}, "projectId": map[string]interface{}{"type": "string"},
"source": map[string]interface{}{"type": "string", "description": "upload/reduction/conversation_artifact"}, "projectName": map[string]interface{}{"type": "string"},
"subPath": map[string]interface{}{"type": "string"},
"source": map[string]interface{}{"type": "string", "description": "upload/reduction/workspace/conversation_artifact"},
}, },
}, },
}, },
@@ -5923,7 +5925,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"parameters": []map[string]interface{}{ "parameters": []map[string]interface{}{
{"name": "conversation", "in": "query", "required": false, "description": "按对话ID过滤", "schema": map[string]interface{}{"type": "string"}}, {"name": "conversation", "in": "query", "required": false, "description": "按对话ID过滤", "schema": map[string]interface{}{"type": "string"}},
{"name": "project", "in": "query", "required": false, "description": "按项目ID过滤", "schema": map[string]interface{}{"type": "string"}}, {"name": "project", "in": "query", "required": false, "description": "按项目ID过滤", "schema": map[string]interface{}{"type": "string"}},
{"name": "source", "in": "query", "required": false, "description": "按来源过滤:upload/reduction/conversation_artifact/all", "schema": map[string]interface{}{"type": "string", "enum": []string{"all", "upload", "reduction", "conversation_artifact"}}}, {"name": "source", "in": "query", "required": false, "description": "按来源过滤:upload/reduction/workspace/conversation_artifact/all", "schema": map[string]interface{}{"type": "string", "enum": []string{"all", "upload", "reduction", "workspace", "conversation_artifact"}}},
{"name": "search", "in": "query", "required": false, "description": "按文件名或子路径搜索", "schema": map[string]interface{}{"type": "string"}}, {"name": "search", "in": "query", "required": false, "description": "按文件名或子路径搜索", "schema": map[string]interface{}{"type": "string"}},
}, },
"responses": map[string]interface{}{ "responses": map[string]interface{}{
@@ -5962,6 +5964,37 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
}, },
}, },
}, },
"/api/chat-uploads/path": map[string]interface{}{
"get": map[string]interface{}{
"tags": []string{"对话附件"},
"summary": "解析附件路径",
"description": "将文件管理中的相对路径或内部虚拟路径解析为服务器绝对路径,用于复制文件/目录路径。",
"operationId": "resolveChatUploadPath",
"parameters": []map[string]interface{}{
{"name": "path", "in": "query", "required": true, "description": "相对路径或虚拟路径(如 __workspace__/projects/<id>/csv", "schema": map[string]interface{}{"type": "string"}},
{"name": "kind", "in": "query", "required": false, "description": "路径类型:file/directory,默认 file", "schema": map[string]interface{}{"type": "string", "enum": []string{"file", "directory"}}},
},
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "解析成功",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"absolutePath": map[string]interface{}{"type": "string"},
"isDir": map[string]interface{}{"type": "boolean"},
},
},
},
},
},
"401": map[string]interface{}{"description": "未授权"},
"403": map[string]interface{}{"description": "无权访问"},
"404": map[string]interface{}{"description": "路径不存在"},
},
},
},
"/api/chat-uploads/content": map[string]interface{}{ "/api/chat-uploads/content": map[string]interface{}{
"get": map[string]interface{}{ "get": map[string]interface{}{
"tags": []string{"对话附件"}, "tags": []string{"对话附件"},
+127
View File
@@ -6,6 +6,8 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"os"
"path/filepath" "path/filepath"
"testing" "testing"
"time" "time"
@@ -146,6 +148,131 @@ func TestChatUploadPathAuthorizationFollowsConversationAccess(t *testing.T) {
} }
} }
func TestChatUploadsListIncludesAuthorizedProjectWorkspaceFiles(t *testing.T) {
db, user := setupConversationRBACTest(t)
fsBase := t.TempDir()
workspaceBase := filepath.Join(fsBase, "workspace")
reductionBase := filepath.Join(fsBase, "reduction")
db.SetEinoConversationDirs("", "", reductionBase, workspaceBase)
allowedProject, _ := db.CreateProject(&database.Project{Name: "allowed"})
hiddenProject, _ := db.CreateProject(&database.Project{Name: "hidden"})
conversation, _ := db.CreateConversation("project conversation", database.ConversationCreateMeta{ProjectID: allowedProject.ID})
if err := db.AssignResourceToUser(user.ID, "project", allowedProject.ID); err != nil {
t.Fatal(err)
}
allowedFile := filepath.Join(workspaceBase, "projects", allowedProject.ID, "csv", "assets.csv")
hiddenFile := filepath.Join(workspaceBase, "projects", hiddenProject.ID, "csv", "secret.csv")
for _, path := range []string{allowedFile, hiddenFile} {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("name\nexample\n"), 0o644); err != nil {
t.Fatal(err)
}
}
h := NewChatUploadsHandler(zap.NewNop(), db)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/api/chat-uploads?source=workspace&pageSize=all&conversation="+conversation.ID, nil)
c.Set(security.ContextSessionKey, security.Session{UserID: user.ID, Scope: database.RBACScopeAssigned})
h.List(c)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200: %s", w.Code, w.Body.String())
}
var response struct {
Files []ChatUploadFileItem `json:"files"`
Total int `json:"total"`
}
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if response.Total != 1 || len(response.Files) != 1 {
t.Fatalf("files = %#v, total = %d, want only authorized workspace file", response.Files, response.Total)
}
got := response.Files[0]
if got.Source != chatUploadSourceWorkspace || got.Name != "assets.csv" || got.ProjectID != allowedProject.ID {
t.Fatalf("workspace file = %#v", got)
}
if got.ProjectName != allowedProject.Name {
t.Fatalf("projectName = %q, want %q", got.ProjectName, allowedProject.Name)
}
if got.ConversationID != conversation.ID {
t.Fatalf("conversationId = %q, want %q", got.ConversationID, conversation.ID)
}
if got.ConversationTitle != conversation.Title {
t.Fatalf("conversationTitle = %q, want %q", got.ConversationTitle, conversation.Title)
}
if got.AbsolutePath != allowedFile {
t.Fatalf("absolutePath = %q, want %q", got.AbsolutePath, allowedFile)
}
w = httptest.NewRecorder()
c, _ = gin.CreateTestContext(w)
resolveURL := "/api/chat-uploads/path?kind=directory&path=__workspace__%2Fprojects%2F" + allowedProject.ID
c.Request = httptest.NewRequest(http.MethodGet, resolveURL, nil)
c.Set(security.ContextSessionKey, security.Session{UserID: user.ID, Scope: database.RBACScopeAssigned})
h.ResolvePath(c)
if w.Code != http.StatusOK {
t.Fatalf("resolve status = %d, want 200: %s", w.Code, w.Body.String())
}
var resolved struct {
AbsolutePath string `json:"absolutePath"`
IsDir bool `json:"isDir"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resolved); err != nil {
t.Fatal(err)
}
wantDir := filepath.Join(workspaceBase, "projects", allowedProject.ID)
if !resolved.IsDir || resolved.AbsolutePath != wantDir {
t.Fatalf("resolved = %#v, want dir %q", resolved, wantDir)
}
w = httptest.NewRecorder()
c, _ = gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/api/chat-uploads/path?kind=directory&path=__workspace__%2Fprojects", nil)
c.Set(security.ContextSessionKey, security.Session{UserID: user.ID, Scope: database.RBACScopeAssigned})
h.ResolvePath(c)
if w.Code != http.StatusOK {
t.Fatalf("resolve projects container status = %d, want 200: %s", w.Code, w.Body.String())
}
if err := json.Unmarshal(w.Body.Bytes(), &resolved); err != nil {
t.Fatal(err)
}
wantContainer := filepath.Join(workspaceBase, "projects")
if !resolved.IsDir || resolved.AbsolutePath != wantContainer {
t.Fatalf("resolved container = %#v, want dir %q", resolved, wantContainer)
}
for _, tc := range []struct {
path string
want string
}{
{"__workspace__/", workspaceBase},
{"__reduction__/", reductionBase},
{"__conversation_artifact__/", db.ConversationArtifactsBaseDir()},
} {
if err := os.MkdirAll(tc.want, 0o755); err != nil {
t.Fatal(err)
}
w = httptest.NewRecorder()
c, _ = gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/api/chat-uploads/path?kind=directory&path="+url.QueryEscape(tc.path), nil)
c.Set(security.ContextSessionKey, security.Session{UserID: user.ID, Scope: database.RBACScopeAssigned})
h.ResolvePath(c)
if w.Code != http.StatusOK {
t.Fatalf("resolve root %q status = %d, want 200: %s", tc.path, w.Code, w.Body.String())
}
if err := json.Unmarshal(w.Body.Bytes(), &resolved); err != nil {
t.Fatal(err)
}
wantAbs, _ := filepath.Abs(tc.want)
if !resolved.IsDir || resolved.AbsolutePath != wantAbs {
t.Fatalf("resolved root %q = %#v, want dir %q", tc.path, resolved, wantAbs)
}
}
}
func TestPrepareMultiAgentSessionRejectsForeignConversation(t *testing.T) { func TestPrepareMultiAgentSessionRejectsForeignConversation(t *testing.T) {
db, user := setupConversationRBACTest(t) db, user := setupConversationRBACTest(t)
hidden, _ := db.CreateConversation("hidden", database.ConversationCreateMeta{}) hidden, _ := db.CreateConversation("hidden", database.ConversationCreateMeta{})
+55 -26
View File
@@ -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 {
+67 -2
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+1358 -42
View File
File diff suppressed because it is too large Load Diff
+58 -6
View File
@@ -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"
@@ -2363,6 +2369,7 @@
"sourceAll": "All", "sourceAll": "All",
"sourceUpload": "Chat uploads", "sourceUpload": "Chat uploads",
"sourceReduction": "Tool outputs", "sourceReduction": "Tool outputs",
"sourceWorkspace": "Workspace files",
"sourceConversationArtifact": "Conversation artifacts", "sourceConversationArtifact": "Conversation artifacts",
"groupBy": "Group by", "groupBy": "Group by",
"groupNone": "None (flat list)", "groupNone": "None (flat list)",
@@ -2379,6 +2386,7 @@
"browseRoot": "Files", "browseRoot": "Files",
"treeUploadsRoot": "Chat uploads", "treeUploadsRoot": "Chat uploads",
"treeReductionRoot": "Tool outputs", "treeReductionRoot": "Tool outputs",
"treeWorkspaceRoot": "Workspace files",
"treeArtifactsRoot": "Conversation artifacts", "treeArtifactsRoot": "Conversation artifacts",
"browseUp": "Up", "browseUp": "Up",
"enterFolderTitle": "Open folder", "enterFolderTitle": "Open folder",
@@ -2588,11 +2596,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 +2644,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 +3873,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 +3991,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",
+58 -6
View File
@@ -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": "人机协同:关闭"
@@ -2351,6 +2357,7 @@
"sourceAll": "全部", "sourceAll": "全部",
"sourceUpload": "对话附件", "sourceUpload": "对话附件",
"sourceReduction": "工具输出", "sourceReduction": "工具输出",
"sourceWorkspace": "工作目录",
"sourceConversationArtifact": "会话产物", "sourceConversationArtifact": "会话产物",
"groupBy": "分组方式", "groupBy": "分组方式",
"groupNone": "不分组(平铺)", "groupNone": "不分组(平铺)",
@@ -2367,6 +2374,7 @@
"browseRoot": "文件", "browseRoot": "文件",
"treeUploadsRoot": "对话附件", "treeUploadsRoot": "对话附件",
"treeReductionRoot": "工具输出", "treeReductionRoot": "工具输出",
"treeWorkspaceRoot": "工作目录",
"treeArtifactsRoot": "会话产物", "treeArtifactsRoot": "会话产物",
"browseUp": "上级", "browseUp": "上级",
"enterFolderTitle": "进入此文件夹", "enterFolderTitle": "进入此文件夹",
@@ -2576,11 +2584,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 +2632,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 +3861,11 @@
"infoLastCheckin": "上次心跳", "infoLastCheckin": "上次心跳",
"infoNote": "备注", "infoNote": "备注",
"infoNoteEmpty": "暂无备注", "infoNoteEmpty": "暂无备注",
"infoCopy": "复制",
"noteEdit": "编辑",
"notePlaceholder": "添加备注,便于识别该会话…",
"toastNoteSaved": "备注已保存",
"toastNoteTooLong": "备注最多 2000 字",
"infoSectionIdentity": "身份信息", "infoSectionIdentity": "身份信息",
"infoSectionSystem": "系统环境", "infoSectionSystem": "系统环境",
"infoSectionNetwork": "网络与信标", "infoSectionNetwork": "网络与信标",
@@ -3940,7 +3979,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",
+1 -1
View File
@@ -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
View File
@@ -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) {
+134 -28
View File
@@ -3,6 +3,8 @@
let chatFilesCache = []; let chatFilesCache = [];
/** 后端 GET /api/chat-uploads 返回的目录相对路径(含空文件夹),与 files 合并成树 */ /** 后端 GET /api/chat-uploads 返回的目录相对路径(含空文件夹),与 files 合并成树 */
let chatFilesFoldersCache = []; let chatFilesFoldersCache = [];
const chatFilesProjectNameById = {};
const chatFilesConversationTitleById = {};
let chatFilesDisplayed = []; let chatFilesDisplayed = [];
let chatFilesEditRelativePath = ''; let chatFilesEditRelativePath = '';
let chatFilesRenameRelativePath = ''; let chatFilesRenameRelativePath = '';
@@ -16,6 +18,7 @@ const CHAT_FILES_BROWSE_PATH_KEY = 'csai_chat_files_browse_path';
const CHAT_FILES_PAGE_SIZE_STORAGE_KEY = 'csai_chat_files_page_size'; const CHAT_FILES_PAGE_SIZE_STORAGE_KEY = 'csai_chat_files_page_size';
const CHAT_FILES_TREE_UPLOAD_ROOT = 'uploads'; const CHAT_FILES_TREE_UPLOAD_ROOT = 'uploads';
const CHAT_FILES_TREE_REDUCTION_ROOT = 'tool_outputs'; const CHAT_FILES_TREE_REDUCTION_ROOT = 'tool_outputs';
const CHAT_FILES_TREE_WORKSPACE_ROOT = 'workspace';
const CHAT_FILES_TREE_ARTIFACT_ROOT = 'conversation_artifacts'; const CHAT_FILES_TREE_ARTIFACT_ROOT = 'conversation_artifacts';
/** 按文件夹浏览模式下的当前路径(虚拟根段数组),如 ['uploads','2024-03-21','uuid'] */ /** 按文件夹浏览模式下的当前路径(虚拟根段数组),如 ['uploads','2024-03-21','uuid'] */
@@ -39,6 +42,19 @@ function closeAllChatFilesFilterSelects() {
}); });
} }
function chatFilesRememberDisplayNames(files) {
Object.keys(chatFilesProjectNameById).forEach(function (k) { delete chatFilesProjectNameById[k]; });
Object.keys(chatFilesConversationTitleById).forEach(function (k) { delete chatFilesConversationTitleById[k]; });
(Array.isArray(files) ? files : []).forEach(function (f) {
const pid = String((f && f.projectId) || '').trim();
const pname = String((f && f.projectName) || '').trim();
if (pid && pname) chatFilesProjectNameById[pid] = pname;
const cid = String((f && f.conversationId) || '').trim();
const ctitle = String((f && f.conversationTitle) || '').trim();
if (cid && ctitle) chatFilesConversationTitleById[cid] = ctitle;
});
}
function syncChatFilesFilterSelect(selectId) { function syncChatFilesFilterSelect(selectId) {
const reg = chatFilesFilterSelectMap[selectId]; const reg = chatFilesFilterSelectMap[selectId];
if (!reg) return; if (!reg) return;
@@ -389,6 +405,7 @@ async function loadChatFilesPage() {
} }
const data = await res.json(); const data = await res.json();
chatFilesCache = Array.isArray(data.files) ? data.files : []; chatFilesCache = Array.isArray(data.files) ? data.files : [];
chatFilesRememberDisplayNames(chatFilesCache);
chatFilesFoldersCache = Array.isArray(data.folders) ? data.folders : []; chatFilesFoldersCache = Array.isArray(data.folders) ? data.folders : [];
chatFilesTotal = Number.isFinite(Number(data.total)) ? Number(data.total) : chatFilesCache.length; chatFilesTotal = Number.isFinite(Number(data.total)) ? Number(data.total) : chatFilesCache.length;
chatFilesPage = Number.isFinite(Number(data.page)) ? Math.max(1, Number(data.page)) : chatFilesPage; chatFilesPage = Number.isFinite(Number(data.page)) ? Math.max(1, Number(data.page)) : chatFilesPage;
@@ -708,6 +725,10 @@ function chatFilesTreePathForFile(f) {
rp = rp.replace(/^__reduction__\//, ''); rp = rp.replace(/^__reduction__\//, '');
return CHAT_FILES_TREE_REDUCTION_ROOT + '/' + rp; return CHAT_FILES_TREE_REDUCTION_ROOT + '/' + rp;
} }
if (source === 'workspace') {
rp = rp.replace(/^__workspace__\//, '');
return CHAT_FILES_TREE_WORKSPACE_ROOT + '/' + rp;
}
if (source === 'conversation_artifact') { if (source === 'conversation_artifact') {
rp = rp.replace(/^__conversation_artifact__\//, ''); rp = rp.replace(/^__conversation_artifact__\//, '');
return CHAT_FILES_TREE_ARTIFACT_ROOT + '/' + rp; return CHAT_FILES_TREE_ARTIFACT_ROOT + '/' + rp;
@@ -768,6 +789,7 @@ function chatFilesEnsureSourceRoots(root) {
const roots = []; const roots = [];
if (source === 'all' || source === 'upload') roots.push(CHAT_FILES_TREE_UPLOAD_ROOT); if (source === 'all' || source === 'upload') roots.push(CHAT_FILES_TREE_UPLOAD_ROOT);
if (source === 'all' || source === 'reduction') roots.push(CHAT_FILES_TREE_REDUCTION_ROOT); if (source === 'all' || source === 'reduction') roots.push(CHAT_FILES_TREE_REDUCTION_ROOT);
if (source === 'all' || source === 'workspace') roots.push(CHAT_FILES_TREE_WORKSPACE_ROOT);
if (source === 'all' || source === 'conversation_artifact') roots.push(CHAT_FILES_TREE_ARTIFACT_ROOT); if (source === 'all' || source === 'conversation_artifact') roots.push(CHAT_FILES_TREE_ARTIFACT_ROOT);
roots.forEach(function (name) { roots.forEach(function (name) {
if (!root.dirs[name]) root.dirs[name] = chatFilesTreeMakeNode(); if (!root.dirs[name]) root.dirs[name] = chatFilesTreeMakeNode();
@@ -776,13 +798,16 @@ function chatFilesEnsureSourceRoots(root) {
function chatFilesIsInternalSource(f) { function chatFilesIsInternalSource(f) {
const source = f && f.source; const source = f && f.source;
return source === 'reduction' || source === 'conversation_artifact'; return source === 'reduction' || source === 'workspace' || source === 'conversation_artifact';
} }
function chatFilesSourceLabel(source) { function chatFilesSourceLabel(source) {
if (source === 'reduction') { if (source === 'reduction') {
return (typeof window.t === 'function') ? window.t('chatFilesPage.sourceReduction') : '工具输出'; return (typeof window.t === 'function') ? window.t('chatFilesPage.sourceReduction') : '工具输出';
} }
if (source === 'workspace') {
return (typeof window.t === 'function') ? window.t('chatFilesPage.sourceWorkspace') : '工作目录';
}
if (source === 'conversation_artifact') { if (source === 'conversation_artifact') {
return (typeof window.t === 'function') ? window.t('chatFilesPage.sourceConversationArtifact') : '会话产物'; return (typeof window.t === 'function') ? window.t('chatFilesPage.sourceConversationArtifact') : '会话产物';
} }
@@ -808,12 +833,66 @@ function chatFilesTreeDisplayName(name) {
if (name === CHAT_FILES_TREE_REDUCTION_ROOT) { if (name === CHAT_FILES_TREE_REDUCTION_ROOT) {
return (typeof window.t === 'function') ? window.t('chatFilesPage.treeReductionRoot') : '工具输出'; return (typeof window.t === 'function') ? window.t('chatFilesPage.treeReductionRoot') : '工具输出';
} }
if (name === CHAT_FILES_TREE_WORKSPACE_ROOT) {
return (typeof window.t === 'function') ? window.t('chatFilesPage.treeWorkspaceRoot') : '工作目录';
}
if (name === CHAT_FILES_TREE_ARTIFACT_ROOT) { if (name === CHAT_FILES_TREE_ARTIFACT_ROOT) {
return (typeof window.t === 'function') ? window.t('chatFilesPage.treeArtifactsRoot') : '会话产物'; return (typeof window.t === 'function') ? window.t('chatFilesPage.treeArtifactsRoot') : '会话产物';
} }
return name; return name;
} }
function chatFilesIDDisplay(id, labelMap, emptyLabel) {
const raw = id == null ? '' : String(id);
if (!raw || raw === '—') {
return { text: emptyLabel || '—', title: '' };
}
const label = String((labelMap && labelMap[raw]) || '').trim();
if (label) {
return { text: label, title: label + ' (' + raw + ')' };
}
if (raw.length > 36) {
return { text: raw.slice(0, 8) + '…' + raw.slice(-6), title: raw };
}
return { text: raw, title: raw };
}
function chatFilesConversationDisplay(id) {
const c = id == null ? '' : String(id);
if (typeof window.t === 'function') {
if (c === '_manual') {
return { text: window.t('chatFilesPage.convManual'), title: '_manual' };
}
if (c === '_new') {
return { text: window.t('chatFilesPage.convNew'), title: '_new' };
}
}
return chatFilesIDDisplay(c, chatFilesConversationTitleById);
}
function chatFilesProjectDisplay(id) {
const empty = (typeof window.t === 'function') ? window.t('chatFilesPage.projectUnbound') : '未绑定项目';
return chatFilesIDDisplay(id, chatFilesProjectNameById, empty);
}
function chatFilesTreePathDisplayName(pathParts) {
const parts = Array.isArray(pathParts) ? pathParts : [];
const name = parts.length ? parts[parts.length - 1] : '';
const root = parts[0] || '';
if ((root === CHAT_FILES_TREE_WORKSPACE_ROOT || root === CHAT_FILES_TREE_REDUCTION_ROOT) && parts.length === 3) {
if (parts[1] === 'projects') return chatFilesProjectDisplay(name);
if (parts[1] === 'conversations') return chatFilesConversationDisplay(name);
}
if (root === CHAT_FILES_TREE_ARTIFACT_ROOT && parts.length === 2) {
return chatFilesConversationDisplay(name);
}
if (root === CHAT_FILES_TREE_UPLOAD_ROOT && parts.length === 3) {
return chatFilesConversationDisplay(name);
}
const text = chatFilesTreeDisplayName(name);
return { text: text, title: String(name || '') };
}
function chatFilesUploadRelativeDirFromBrowsePath(path) { function chatFilesUploadRelativeDirFromBrowsePath(path) {
const parts = Array.isArray(path) ? path.slice() : []; const parts = Array.isArray(path) ? path.slice() : [];
if (parts[0] === CHAT_FILES_TREE_UPLOAD_ROOT) { if (parts[0] === CHAT_FILES_TREE_UPLOAD_ROOT) {
@@ -893,32 +972,15 @@ function chatFilesBuildGroups(files, mode) {
/** 分组标题:长 ID 缩短展示,完整值放在 title */ /** 分组标题:长 ID 缩短展示,完整值放在 title */
function chatFilesGroupHeadingID(key, emptyLabel) { function chatFilesGroupHeadingID(key, emptyLabel) {
const c = key == null ? '' : String(key); return chatFilesIDDisplay(key, null, emptyLabel);
if (c === '' || c === '—') {
return { text: emptyLabel || '—', title: '' };
}
if (c.length > 36) {
return { text: c.slice(0, 8) + '…' + c.slice(-6), title: c };
}
return { text: c, title: c };
} }
function chatFilesGroupHeadingConversation(key) { function chatFilesGroupHeadingConversation(key) {
const c = key == null ? '' : String(key); return chatFilesConversationDisplay(key);
if (typeof window.t === 'function') {
if (c === '_manual') {
return { text: window.t('chatFilesPage.convManual'), title: '_manual' };
}
if (c === '_new') {
return { text: window.t('chatFilesPage.convNew'), title: '_new' };
}
}
return chatFilesGroupHeadingID(key);
} }
function chatFilesGroupHeadingProject(key) { function chatFilesGroupHeadingProject(key) {
const empty = (typeof window.t === 'function') ? window.t('chatFilesPage.projectUnbound') : '未绑定项目'; return chatFilesProjectDisplay(key);
return chatFilesGroupHeadingID(key, empty);
} }
function renderChatFilesTable() { function renderChatFilesTable() {
@@ -965,7 +1027,9 @@ function renderChatFilesTable() {
const isInternal = chatFilesIsInternalSource(f); const isInternal = chatFilesIsInternalSource(f);
const sourceBadge = isInternal ? '<span class="chat-files-source-badge">' + escapeHtml(chatFilesSourceLabel(f.source)) + '</span>' : ''; const sourceBadge = isInternal ? '<span class="chat-files-source-badge">' + escapeHtml(chatFilesSourceLabel(f.source)) + '</span>' : '';
const conv = f.conversationId || ''; const conv = f.conversationId || '';
const convEsc = escapeHtml(conv); const convDisplay = chatFilesConversationDisplay(conv);
const convEsc = escapeHtml(convDisplay.text || conv);
const convTitleEsc = escapeHtml(convDisplay.title || conv);
const dt = f.modifiedUnix ? new Date(f.modifiedUnix * 1000).toLocaleString() : '—'; const dt = f.modifiedUnix ? new Date(f.modifiedUnix * 1000).toLocaleString() : '—';
const canOpenChat = conv && conv !== '_manual' && conv !== '_new'; const canOpenChat = conv && conv !== '_manual' && conv !== '_new';
@@ -1011,7 +1075,7 @@ function renderChatFilesTable() {
return `<tr> return `<tr>
<td>${escapeHtml(f.date || '—')}</td> <td>${escapeHtml(f.date || '—')}</td>
<td class="chat-files-cell-conv"><code title="${convEsc}">${convEsc}</code></td> <td class="chat-files-cell-conv"><code title="${convTitleEsc}">${convEsc}</code></td>
<td class="chat-files-cell-subpath" title="${escapeHtml(subRaw || '')}">${subCellInner}</td> <td class="chat-files-cell-subpath" title="${escapeHtml(subRaw || '')}">${subCellInner}</td>
<td class="chat-files-cell-name" title="${escapeHtml(pathForTitle)}">${nameEsc}${sourceBadge}</td> <td class="chat-files-cell-name" title="${escapeHtml(pathForTitle)}">${nameEsc}${sourceBadge}</td>
<td>${formatChatFileBytes(f.size || 0)}</td> <td>${formatChatFileBytes(f.size || 0)}</td>
@@ -1072,11 +1136,14 @@ function renderChatFilesTable() {
for (bi = 0; bi < chatFilesBrowsePath.length; bi++) { for (bi = 0; bi < chatFilesBrowsePath.length; bi++) {
const seg = chatFilesBrowsePath[bi]; const seg = chatFilesBrowsePath[bi];
const isLast = bi === chatFilesBrowsePath.length - 1; const isLast = bi === chatFilesBrowsePath.length - 1;
const display = chatFilesTreePathDisplayName(chatFilesBrowsePath.slice(0, bi + 1));
const displayText = escapeHtml(display.text);
const displayTitle = display.title ? ' title="' + escapeHtml(display.title) + '"' : '';
breadcrumbHtml += '<span class="chat-files-breadcrumb-sep">/</span>'; breadcrumbHtml += '<span class="chat-files-breadcrumb-sep">/</span>';
if (isLast) { if (isLast) {
breadcrumbHtml += '<span class="chat-files-breadcrumb-current">' + escapeHtml(chatFilesTreeDisplayName(seg)) + '</span>'; breadcrumbHtml += '<span class="chat-files-breadcrumb-current"' + displayTitle + '>' + displayText + '</span>';
} else { } else {
breadcrumbHtml += '<button type="button" class="chat-files-breadcrumb-link" onclick="chatFilesNavigateBreadcrumb(' + bi + ')">' + escapeHtml(chatFilesTreeDisplayName(seg)) + '</button>'; breadcrumbHtml += '<button type="button" class="chat-files-breadcrumb-link" onclick="chatFilesNavigateBreadcrumb(' + bi + ')"' + displayTitle + '>' + displayText + '</button>';
} }
} }
breadcrumbHtml += '</nav>'; breadcrumbHtml += '</nav>';
@@ -1098,6 +1165,8 @@ function renderChatFilesTable() {
function rowHtmlBrowseFolder(name) { function rowHtmlBrowseFolder(name) {
const nameAttr = encodeURIComponent(String(name)); const nameAttr = encodeURIComponent(String(name));
const folderPath = chatFilesBrowsePath.concat([name]); const folderPath = chatFilesBrowsePath.concat([name]);
const folderDisplay = chatFilesTreePathDisplayName(folderPath);
const folderTitle = folderDisplay.title || tEnter;
const relToFolder = folderPath.join('/'); const relToFolder = folderPath.join('/');
const uploadDirAttr = encodeURIComponent(relToFolder); const uploadDirAttr = encodeURIComponent(relToFolder);
const canUploadFolder = chatFilesBrowseCanUploadToPath(folderPath); const canUploadFolder = chatFilesBrowseCanUploadToPath(folderPath);
@@ -1109,8 +1178,8 @@ function renderChatFilesTable() {
? `<button type="button" class="btn-icon btn-danger" title="${tDeleteFolder}" data-chat-folder-name="${nameAttr}" onclick="chatFilesDeleteFolderFromBtn(event, this)">${svgTrash}</button>` ? `<button type="button" class="btn-icon btn-danger" title="${tDeleteFolder}" data-chat-folder-name="${nameAttr}" onclick="chatFilesDeleteFolderFromBtn(event, this)">${svgTrash}</button>`
: ''; : '';
return `<tr class="chat-files-tr-folder chat-files-tr-folder--nav" role="button" tabindex="0" data-chat-folder-name="${nameAttr}" onclick="chatFilesOnFolderRowClick(event)" onkeydown="chatFilesOnFolderRowKeydown(event)"> return `<tr class="chat-files-tr-folder chat-files-tr-folder--nav" role="button" tabindex="0" data-chat-folder-name="${nameAttr}" onclick="chatFilesOnFolderRowClick(event)" onkeydown="chatFilesOnFolderRowKeydown(event)">
<td class="chat-files-tree-name-cell chat-files-tree-name-cell--folder" title="${tEnter}"> <td class="chat-files-tree-name-cell chat-files-tree-name-cell--folder" title="${escapeHtml(folderTitle)}">
<span class="chat-files-tree-name-inner">${svgFolder}<span class="chat-files-tree-name-text">${escapeHtml(chatFilesTreeDisplayName(name))}</span></span> <span class="chat-files-tree-name-inner">${svgFolder}<span class="chat-files-tree-name-text">${escapeHtml(folderDisplay.text)}</span></span>
</td> </td>
<td class="chat-files-tree-muted"></td> <td class="chat-files-tree-muted"></td>
<td class="chat-files-tree-muted"></td> <td class="chat-files-tree-muted"></td>
@@ -1351,7 +1420,25 @@ function chatFilesDeleteFolderFromBtn(ev, btn) {
async function copyChatFolderPathFromBrowse(folderName) { async function copyChatFolderPathFromBrowse(folderName) {
const segs = chatFilesBrowsePath.concat([folderName]); const segs = chatFilesBrowsePath.concat([folderName]);
const text = chatFilesClipboardFolderPath(segs); const relativePath = chatFilesRelativePathFromTreeSegments(segs);
let text = '';
if (relativePath) {
try {
const res = await apiFetch('/api/chat-uploads/path?kind=directory&path=' + encodeURIComponent(relativePath));
if (!res.ok) {
const raw = await res.text();
throw new Error(raw || String(res.status));
}
const data = await res.json();
text = String((data && data.absolutePath) || '').trim();
} catch (e) {
alert((e && e.message) ? e.message : String(e));
return;
}
}
if (!text) {
text = chatFilesClipboardFolderPath(segs);
}
const ok = await chatFilesCopyText(text); const ok = await chatFilesCopyText(text);
if (ok) { if (ok) {
const msg = (typeof window.t === 'function') ? window.t('chatFilesPage.folderPathCopied') : '目录路径已复制'; const msg = (typeof window.t === 'function') ? window.t('chatFilesPage.folderPathCopied') : '目录路径已复制';
@@ -1362,6 +1449,25 @@ async function copyChatFolderPathFromBrowse(folderName) {
} }
} }
function chatFilesRelativePathFromTreeSegments(segs) {
const parts = Array.isArray(segs) ? segs.slice() : [];
if (!parts.length) return '';
const root = parts.shift();
if (root === CHAT_FILES_TREE_UPLOAD_ROOT) {
return parts.length ? parts.join('/') : '.';
}
if (root === CHAT_FILES_TREE_REDUCTION_ROOT) {
return '__reduction__/' + parts.join('/');
}
if (root === CHAT_FILES_TREE_WORKSPACE_ROOT) {
return '__workspace__/' + parts.join('/');
}
if (root === CHAT_FILES_TREE_ARTIFACT_ROOT) {
return '__conversation_artifact__/' + parts.join('/');
}
return [root].concat(parts).join('/');
}
function chatFilesClipboardFolderPath(segs) { function chatFilesClipboardFolderPath(segs) {
const parts = Array.isArray(segs) ? segs.slice() : []; const parts = Array.isArray(segs) ? segs.slice() : [];
if (!parts.length) return ''; if (!parts.length) return '';
+354 -10
View File
@@ -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) {
+14 -4
View File
@@ -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)
]); ]);
+23
View File
@@ -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
View File
@@ -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) {
+6 -1
View File
@@ -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');
+614 -55
View File
@@ -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();
@@ -34,6 +38,16 @@ function closeSettingsCustomSelect(select) {
if (reg) { if (reg) {
reg.wrapper.classList.remove('open'); reg.wrapper.classList.remove('open');
reg.trigger.setAttribute('aria-expanded', 'false'); reg.trigger.setAttribute('aria-expanded', 'false');
if (reg.menu.parentNode !== reg.wrapper) {
reg.wrapper.appendChild(reg.menu);
}
reg.menu.classList.remove('settings-custom-select-menu--floating');
reg.menu.style.left = '';
reg.menu.style.right = '';
reg.menu.style.top = '';
reg.menu.style.bottom = '';
reg.menu.style.width = '';
reg.menu.style.maxHeight = '';
} }
} }
@@ -41,9 +55,62 @@ function closeAllSettingsCustomSelects() {
settingsCustomSelects.forEach((reg) => { settingsCustomSelects.forEach((reg) => {
reg.wrapper.classList.remove('open'); reg.wrapper.classList.remove('open');
reg.trigger.setAttribute('aria-expanded', 'false'); reg.trigger.setAttribute('aria-expanded', 'false');
if (reg.menu.parentNode !== reg.wrapper) {
reg.wrapper.appendChild(reg.menu);
}
reg.menu.classList.remove('settings-custom-select-menu--floating');
reg.menu.style.left = '';
reg.menu.style.right = '';
reg.menu.style.top = '';
reg.menu.style.bottom = '';
reg.menu.style.width = '';
reg.menu.style.maxHeight = '';
}); });
} }
function positionSettingsCustomSelectMenu(reg) {
if (!reg || !reg.wrapper.classList.contains('open')) return;
const rect = reg.trigger.getBoundingClientRect();
const viewportWidth = window.innerWidth || document.documentElement.clientWidth || 0;
const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0;
const gap = 6;
const edgePadding = 12;
const desiredWidth = Math.max(rect.width, 150);
const width = Math.min(desiredWidth, Math.max(160, viewportWidth - edgePadding * 2));
const left = Math.min(Math.max(edgePadding, rect.left), Math.max(edgePadding, viewportWidth - width - edgePadding));
const spaceBelow = viewportHeight - rect.bottom - gap - edgePadding;
const spaceAbove = rect.top - gap - edgePadding;
const openAbove = spaceBelow < 180 && spaceAbove > spaceBelow;
const maxHeight = Math.max(120, Math.floor((openAbove ? spaceAbove : spaceBelow) || 180));
reg.menu.style.left = `${Math.round(left)}px`;
reg.menu.style.right = 'auto';
reg.menu.style.width = `${Math.round(width)}px`;
reg.menu.style.maxHeight = `${maxHeight}px`;
if (openAbove) {
reg.menu.style.top = 'auto';
reg.menu.style.bottom = `${Math.round(viewportHeight - rect.top + gap)}px`;
} else {
reg.menu.style.top = `${Math.round(rect.bottom + gap)}px`;
reg.menu.style.bottom = 'auto';
}
}
function openSettingsCustomSelect(select) {
const reg = settingsCustomSelects.get(select);
if (!reg || select.disabled) return;
closeAllSettingsCustomSelects();
reg.wrapper.classList.add('open');
reg.trigger.setAttribute('aria-expanded', 'true');
reg.menu.classList.add('settings-custom-select-menu--floating');
document.body.appendChild(reg.menu);
positionSettingsCustomSelectMenu(reg);
}
function repositionOpenSettingsCustomSelects() {
settingsCustomSelects.forEach((reg) => positionSettingsCustomSelectMenu(reg));
}
function syncSettingsCustomSelect(select) { function syncSettingsCustomSelect(select) {
const reg = settingsCustomSelects.get(select); const reg = settingsCustomSelects.get(select);
if (!reg) return; if (!reg) return;
@@ -135,9 +202,8 @@ function enhanceSettingsSelect(select) {
event.stopPropagation(); event.stopPropagation();
if (select.disabled) return; if (select.disabled) return;
const willOpen = !wrapper.classList.contains('open'); const willOpen = !wrapper.classList.contains('open');
closeAllSettingsCustomSelects(); if (willOpen) openSettingsCustomSelect(select);
wrapper.classList.toggle('open', willOpen); else closeSettingsCustomSelect(select);
trigger.setAttribute('aria-expanded', willOpen ? 'true' : 'false');
}); });
trigger.addEventListener('keydown', (event) => { trigger.addEventListener('keydown', (event) => {
@@ -154,8 +220,7 @@ function enhanceSettingsSelect(select) {
closeSettingsCustomSelect(select); closeSettingsCustomSelect(select);
return; return;
} else if (event.key === 'Enter' || event.key === ' ') { } else if (event.key === 'Enter' || event.key === ' ') {
wrapper.classList.add('open'); openSettingsCustomSelect(select);
trigger.setAttribute('aria-expanded', 'true');
event.preventDefault(); event.preventDefault();
return; return;
} else { } else {
@@ -196,6 +261,8 @@ function initSettingsCustomSelects(root) {
document.addEventListener('keydown', (event) => { document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') closeAllSettingsCustomSelects(); if (event.key === 'Escape') closeAllSettingsCustomSelects();
}); });
document.addEventListener('scroll', repositionOpenSettingsCustomSelects, true);
window.addEventListener('resize', repositionOpenSettingsCustomSelects);
settingsCustomSelectsDocBound = true; settingsCustomSelectsDocBound = true;
} }
refreshSettingsCustomSelects(); refreshSettingsCustomSelects();
@@ -668,39 +735,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 +1935,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 +1954,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 +2469,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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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 +3500,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,
+206 -3
View File
@@ -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,15 @@ 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';
populateWebshellProjectSelect('');
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 +4861,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 +5162,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';
+203 -103
View File
@@ -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;">
@@ -2741,6 +2727,7 @@
<option value="all" data-i18n="chatFilesPage.sourceAll">全部</option> <option value="all" data-i18n="chatFilesPage.sourceAll">全部</option>
<option value="upload" data-i18n="chatFilesPage.sourceUpload">对话附件</option> <option value="upload" data-i18n="chatFilesPage.sourceUpload">对话附件</option>
<option value="reduction" data-i18n="chatFilesPage.sourceReduction">工具输出</option> <option value="reduction" data-i18n="chatFilesPage.sourceReduction">工具输出</option>
<option value="workspace" data-i18n="chatFilesPage.sourceWorkspace">工作目录</option>
<option value="conversation_artifact" data-i18n="chatFilesPage.sourceConversationArtifact">会话产物</option> <option value="conversation_artifact" data-i18n="chatFilesPage.sourceConversationArtifact">会话产物</option>
</select> </select>
</label> </label>
@@ -2827,8 +2814,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 +2841,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 +3440,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 +3514,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 +3554,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 +3577,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 +6210,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 +6644,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>