mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-05-17 21:44:43 +02:00
Compare commits
22 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 |
@@ -174,10 +174,20 @@ go build -o cyberstrike-ai cmd/server/main.go
|
|||||||
|
|
||||||
### Version Update (No Breaking Changes)
|
### Version Update (No Breaking Changes)
|
||||||
|
|
||||||
**CyberStrikeAI version update (when there are no compatibility changes):**
|
**CyberStrikeAI one-click upgrade (recommended):**
|
||||||
1. Download the latest source code.
|
1. (First time) enable the script: `chmod +x upgrade.sh`
|
||||||
2. Copy the old project's `/data` folder and `config.yaml` file into the new source directory.
|
2. Upgrade with: `./upgrade.sh` (optional flags: `--tag vX.Y.Z`, `--no-venv`, `--preserve-custom`, `--yes`)
|
||||||
3. Restart with: `chmod +x run.sh && ./run.sh`
|
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.
|
⚠️ **Note:** This procedure only applies to version updates without compatibility or breaking changes. If a release includes compatibility changes, this method may not apply.
|
||||||
|
|
||||||
|
|||||||
+13
-3
@@ -173,9 +173,19 @@ go build -o cyberstrike-ai cmd/server/main.go
|
|||||||
|
|
||||||
### CyberStrikeAI 版本更新(无兼容性问题)
|
### CyberStrikeAI 版本更新(无兼容性问题)
|
||||||
|
|
||||||
1. 下载最新源代码;
|
1. (首次使用)启用脚本:`chmod +x upgrade.sh`
|
||||||
2. 将旧项目的 `/data` 文件夹、`config.yaml` 文件复制至新版源代码目录;
|
2. 一键升级:`./upgrade.sh`(可选参数:`--tag vX.Y.Z`、`--no-venv`、`--preserve-custom`、`--yes`)
|
||||||
3. 执行命令重启:`chmod +x run.sh && ./run.sh`
|
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`。
|
||||||
|
|
||||||
⚠️ **注意:** 仅适用于无兼容性变更的版本更新。若版本存在兼容性调整,此方法不适用。
|
⚠️ **注意:** 仅适用于无兼容性变更的版本更新。若版本存在兼容性调整,此方法不适用。
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||||
version: "v1.3.27"
|
version: "v1.3.29"
|
||||||
|
|
||||||
# 服务器配置
|
# 服务器配置
|
||||||
server:
|
server:
|
||||||
|
|||||||
+295
-33
@@ -15,6 +15,7 @@ import (
|
|||||||
"cyberstrike-ai/internal/mcp"
|
"cyberstrike-ai/internal/mcp"
|
||||||
"cyberstrike-ai/internal/mcp/builtin"
|
"cyberstrike-ai/internal/mcp/builtin"
|
||||||
"cyberstrike-ai/internal/openai"
|
"cyberstrike-ai/internal/openai"
|
||||||
|
"cyberstrike-ai/internal/security"
|
||||||
"cyberstrike-ai/internal/storage"
|
"cyberstrike-ai/internal/storage"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -196,6 +197,7 @@ type OpenAIRequest struct {
|
|||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
Messages []ChatMessage `json:"messages"`
|
Messages []ChatMessage `json:"messages"`
|
||||||
Tools []Tool `json:"tools,omitempty"`
|
Tools []Tool `json:"tools,omitempty"`
|
||||||
|
Stream bool `json:"stream,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenAIResponse OpenAI API响应
|
// OpenAIResponse OpenAI API响应
|
||||||
@@ -529,6 +531,7 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
|
|||||||
var currentReActInput string
|
var currentReActInput string
|
||||||
|
|
||||||
maxIterations := a.maxIterations
|
maxIterations := a.maxIterations
|
||||||
|
thinkingStreamSeq := 0
|
||||||
for i := 0; i < maxIterations; i++ {
|
for i := 0; i < maxIterations; i++ {
|
||||||
// 先获取本轮可用工具并统计 tools token,再压缩,以便压缩时预留 tools 占用的空间
|
// 先获取本轮可用工具并统计 tools token,再压缩,以便压缩时预留 tools 占用的空间
|
||||||
tools := a.getAvailableTools(roleTools)
|
tools := a.getAvailableTools(roleTools)
|
||||||
@@ -630,7 +633,28 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
|
|||||||
|
|
||||||
// 调用OpenAI
|
// 调用OpenAI
|
||||||
sendProgress("progress", "正在调用AI模型...", nil)
|
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 {
|
if err != nil {
|
||||||
// API调用失败,保存当前的ReAct输入和错误信息作为输出
|
// API调用失败,保存当前的ReAct输入和错误信息作为输出
|
||||||
result.LastReActInput = currentReActInput
|
result.LastReActInput = currentReActInput
|
||||||
@@ -682,10 +706,12 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
|
|||||||
|
|
||||||
// 检查是否有工具调用
|
// 检查是否有工具调用
|
||||||
if len(choice.Message.ToolCalls) > 0 {
|
if len(choice.Message.ToolCalls) > 0 {
|
||||||
// 如果有思考内容,先发送思考事件
|
// 思考内容:如果本轮启用了思考流式增量(thinking_stream_*),前端会去重;
|
||||||
|
// 同时也需要在该“思考阶段结束”时补一条可落库的 thinking(用于刷新后持久化展示)。
|
||||||
if choice.Message.Content != "" {
|
if choice.Message.Content != "" {
|
||||||
sendProgress("thinking", choice.Message.Content, map[string]interface{}{
|
sendProgress("thinking", choice.Message.Content, map[string]interface{}{
|
||||||
"iteration": i + 1,
|
"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 {
|
if err != nil {
|
||||||
// 构建详细的错误信息,帮助AI理解问题并做出决策
|
// 构建详细的错误信息,帮助AI理解问题并做出决策
|
||||||
errorMsg := a.formatToolError(toolCall.Function.Name, toolCall.Function.Arguments, err)
|
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: "这是最后一次迭代。请总结到目前为止的所有测试结果、发现的问题和已完成的工作。如果需要继续测试,请提供详细的下一步执行计划。请直接回复,不要调用工具。",
|
Content: "这是最后一次迭代。请总结到目前为止的所有测试结果、发现的问题和已完成的工作。如果需要继续测试,请提供详细的下一步执行计划。请直接回复,不要调用工具。",
|
||||||
})
|
})
|
||||||
messages = a.applyMemoryCompression(ctx, messages, 0) // 总结时不带 tools,不预留
|
messages = a.applyMemoryCompression(ctx, messages, 0) // 总结时不带 tools,不预留
|
||||||
// 立即调用OpenAI获取总结
|
// 流式调用OpenAI获取总结(不提供工具,强制AI直接回复)
|
||||||
summaryResponse, err := a.callOpenAI(ctx, messages, []Tool{}) // 不提供工具,强制AI直接回复
|
sendProgress("response_start", "", map[string]interface{}{
|
||||||
if err == nil && summaryResponse != nil && len(summaryResponse.Choices) > 0 {
|
"conversationId": conversationID,
|
||||||
summaryChoice := summaryResponse.Choices[0]
|
"mcpExecutionIds": result.MCPExecutionIDs,
|
||||||
if summaryChoice.Message.Content != "" {
|
"messageGeneratedBy": "summary",
|
||||||
result.Response = summaryChoice.Message.Content
|
})
|
||||||
result.LastReActOutput = result.Response
|
streamText, _ := a.callOpenAIStreamText(ctx, messages, []Tool{}, func(delta string) error {
|
||||||
sendProgress("progress", "总结生成完成", nil)
|
sendProgress("response_delta", delta, map[string]interface{}{
|
||||||
return result, nil
|
"conversationId": conversationID,
|
||||||
}
|
})
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if strings.TrimSpace(streamText) != "" {
|
||||||
|
result.Response = streamText
|
||||||
|
result.LastReActOutput = result.Response
|
||||||
|
sendProgress("progress", "总结生成完成", nil)
|
||||||
|
return result, nil
|
||||||
}
|
}
|
||||||
// 如果获取总结失败,跳出循环,让后续逻辑处理
|
// 如果获取总结失败,跳出循环,让后续逻辑处理
|
||||||
break
|
break
|
||||||
@@ -817,7 +864,7 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 发送AI思考内容(如果没有工具调用)
|
// 发送AI思考内容(如果没有工具调用)
|
||||||
if choice.Message.Content != "" {
|
if choice.Message.Content != "" && !thinkingStreamStarted {
|
||||||
sendProgress("thinking", choice.Message.Content, map[string]interface{}{
|
sendProgress("thinking", choice.Message.Content, map[string]interface{}{
|
||||||
"iteration": i + 1,
|
"iteration": i + 1,
|
||||||
})
|
})
|
||||||
@@ -832,16 +879,23 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
|
|||||||
Content: "这是最后一次迭代。请总结到目前为止的所有测试结果、发现的问题和已完成的工作。如果需要继续测试,请提供详细的下一步执行计划。请直接回复,不要调用工具。",
|
Content: "这是最后一次迭代。请总结到目前为止的所有测试结果、发现的问题和已完成的工作。如果需要继续测试,请提供详细的下一步执行计划。请直接回复,不要调用工具。",
|
||||||
})
|
})
|
||||||
messages = a.applyMemoryCompression(ctx, messages, 0) // 总结时不带 tools,不预留
|
messages = a.applyMemoryCompression(ctx, messages, 0) // 总结时不带 tools,不预留
|
||||||
// 立即调用OpenAI获取总结
|
// 流式调用OpenAI获取总结(不提供工具,强制AI直接回复)
|
||||||
summaryResponse, err := a.callOpenAI(ctx, messages, []Tool{}) // 不提供工具,强制AI直接回复
|
sendProgress("response_start", "", map[string]interface{}{
|
||||||
if err == nil && summaryResponse != nil && len(summaryResponse.Choices) > 0 {
|
"conversationId": conversationID,
|
||||||
summaryChoice := summaryResponse.Choices[0]
|
"mcpExecutionIds": result.MCPExecutionIDs,
|
||||||
if summaryChoice.Message.Content != "" {
|
"messageGeneratedBy": "summary",
|
||||||
result.Response = summaryChoice.Message.Content
|
})
|
||||||
result.LastReActOutput = result.Response
|
streamText, _ := a.callOpenAIStreamText(ctx, messages, []Tool{}, func(delta string) error {
|
||||||
sendProgress("progress", "总结生成完成", nil)
|
sendProgress("response_delta", delta, map[string]interface{}{
|
||||||
return result, nil
|
"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 != "" {
|
if choice.Message.Content != "" {
|
||||||
@@ -872,15 +926,23 @@ func (a *Agent) AgentLoopWithProgress(ctx context.Context, userInput string, his
|
|||||||
messages = append(messages, finalSummaryPrompt)
|
messages = append(messages, finalSummaryPrompt)
|
||||||
messages = a.applyMemoryCompression(ctx, messages, 0) // 总结时不带 tools,不预留
|
messages = a.applyMemoryCompression(ctx, messages, 0) // 总结时不带 tools,不预留
|
||||||
|
|
||||||
summaryResponse, err := a.callOpenAI(ctx, messages, []Tool{}) // 不提供工具,强制AI直接回复
|
// 流式调用OpenAI获取总结(不提供工具,强制AI直接回复)
|
||||||
if err == nil && summaryResponse != nil && len(summaryResponse.Choices) > 0 {
|
sendProgress("response_start", "", map[string]interface{}{
|
||||||
summaryChoice := summaryResponse.Choices[0]
|
"conversationId": conversationID,
|
||||||
if summaryChoice.Message.Content != "" {
|
"mcpExecutionIds": result.MCPExecutionIDs,
|
||||||
result.Response = summaryChoice.Message.Content
|
"messageGeneratedBy": "max_iter_summary",
|
||||||
result.LastReActOutput = result.Response
|
})
|
||||||
sendProgress("progress", "总结生成完成", nil)
|
streamText, _ := a.callOpenAIStreamText(ctx, messages, []Tool{}, func(delta string) error {
|
||||||
return result, nil
|
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
|
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 工具执行结果
|
// ToolExecutionResult 工具执行结果
|
||||||
type ToolExecutionResult struct {
|
type ToolExecutionResult struct {
|
||||||
Result string
|
Result string
|
||||||
|
|||||||
@@ -320,6 +320,7 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
|
|||||||
attackChainHandler := handler.NewAttackChainHandler(db, &cfg.OpenAI, log.Logger)
|
attackChainHandler := handler.NewAttackChainHandler(db, &cfg.OpenAI, log.Logger)
|
||||||
vulnerabilityHandler := handler.NewVulnerabilityHandler(db, log.Logger)
|
vulnerabilityHandler := handler.NewVulnerabilityHandler(db, log.Logger)
|
||||||
webshellHandler := handler.NewWebShellHandler(log.Logger, db)
|
webshellHandler := handler.NewWebShellHandler(log.Logger, db)
|
||||||
|
chatUploadsHandler := handler.NewChatUploadsHandler(log.Logger)
|
||||||
registerWebshellTools(mcpServer, db, webshellHandler, log.Logger)
|
registerWebshellTools(mcpServer, db, webshellHandler, log.Logger)
|
||||||
configHandler := handler.NewConfigHandler(configPath, cfg, mcpServer, executor, agent, attackChainHandler, externalMCPMgr, log.Logger)
|
configHandler := handler.NewConfigHandler(configPath, cfg, mcpServer, executor, agent, attackChainHandler, externalMCPMgr, log.Logger)
|
||||||
externalMCPHandler := handler.NewExternalMCPHandler(externalMCPMgr, cfg, configPath, 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
|
app, // 传递 App 实例以便动态获取 knowledgeHandler
|
||||||
vulnerabilityHandler,
|
vulnerabilityHandler,
|
||||||
webshellHandler,
|
webshellHandler,
|
||||||
|
chatUploadsHandler,
|
||||||
roleHandler,
|
roleHandler,
|
||||||
skillsHandler,
|
skillsHandler,
|
||||||
fofaHandler,
|
fofaHandler,
|
||||||
@@ -567,6 +569,7 @@ func setupRoutes(
|
|||||||
app *App, // 传递 App 实例以便动态获取 knowledgeHandler
|
app *App, // 传递 App 实例以便动态获取 knowledgeHandler
|
||||||
vulnerabilityHandler *handler.VulnerabilityHandler,
|
vulnerabilityHandler *handler.VulnerabilityHandler,
|
||||||
webshellHandler *handler.WebShellHandler,
|
webshellHandler *handler.WebShellHandler,
|
||||||
|
chatUploadsHandler *handler.ChatUploadsHandler,
|
||||||
roleHandler *handler.RoleHandler,
|
roleHandler *handler.RoleHandler,
|
||||||
skillsHandler *handler.SkillsHandler,
|
skillsHandler *handler.SkillsHandler,
|
||||||
fofaHandler *handler.FofaHandler,
|
fofaHandler *handler.FofaHandler,
|
||||||
@@ -838,6 +841,15 @@ func setupRoutes(
|
|||||||
protected.POST("/webshell/exec", webshellHandler.Exec)
|
protected.POST("/webshell/exec", webshellHandler.Exec)
|
||||||
protected.POST("/webshell/file", webshellHandler.FileOp)
|
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", roleHandler.GetRoles)
|
||||||
protected.GET("/roles/:name", roleHandler.GetRole)
|
protected.GET("/roles/:name", roleHandler.GetRole)
|
||||||
|
|||||||
@@ -662,8 +662,16 @@ func (h *AgentHandler) createProgressCallback(conversationID, assistantMessageID
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存过程详情到数据库(排除response和done事件,它们会在后面单独处理)
|
// 保存过程详情到数据库(排除response/done事件,它们会在后面单独处理)
|
||||||
if assistantMessageID != "" && eventType != "response" && eventType != "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 {
|
if err := h.db.AddProcessDetail(assistantMessageID, conversationID, eventType, message, data); err != nil {
|
||||||
h.logger.Warn("保存过程详情失败", zap.Error(err), zap.String("eventType", eventType))
|
h.logger.Warn("保存过程详情失败", zap.Error(err), zap.String("eventType", eventType))
|
||||||
}
|
}
|
||||||
@@ -703,8 +711,53 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
|
|||||||
// 发送初始事件
|
// 发送初始事件
|
||||||
// 用于跟踪客户端是否已断开连接
|
// 用于跟踪客户端是否已断开连接
|
||||||
clientDisconnected := false
|
clientDisconnected := false
|
||||||
|
// 用于快速确认模型是否真的产生了流式 delta
|
||||||
|
var responseDeltaCount int
|
||||||
|
var responseStartLogged bool
|
||||||
|
|
||||||
sendEvent := func(eventType, message string, data interface{}) {
|
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 {
|
if clientDisconnected {
|
||||||
return
|
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
|
package openai
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -142,3 +143,342 @@ func (c *Client) ChatCompletion(ctx context.Context, payload interface{}, out in
|
|||||||
|
|
||||||
return nil
|
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"
|
"os/exec"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"cyberstrike-ai/internal/config"
|
"cyberstrike-ai/internal/config"
|
||||||
"cyberstrike-ai/internal/mcp"
|
"cyberstrike-ai/internal/mcp"
|
||||||
@@ -17,6 +19,15 @@ import (
|
|||||||
"go.uber.org/zap"
|
"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 安全工具执行器
|
// Executor 安全工具执行器
|
||||||
type Executor struct {
|
type Executor struct {
|
||||||
config *config.SecurityConfig
|
config *config.SecurityConfig
|
||||||
@@ -144,7 +155,16 @@ func (e *Executor) ExecuteTool(ctx context.Context, toolName string, args map[st
|
|||||||
zap.Strings("args", cmdArgs),
|
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 {
|
if err != nil {
|
||||||
// 检查退出码是否在允许列表中
|
// 检查退出码是否在允许列表中
|
||||||
exitCode := getExitCode(err)
|
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 {
|
if err != nil {
|
||||||
e.logger.Error("系统命令执行失败",
|
e.logger.Error("系统命令执行失败",
|
||||||
zap.String("command", command),
|
zap.String("command", command),
|
||||||
@@ -965,6 +994,78 @@ func (e *Executor) executeSystemCommand(ctx context.Context, args map[string]int
|
|||||||
}, nil
|
}, 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 执行内部工具(不执行外部命令)
|
// executeInternalTool 执行内部工具(不执行外部命令)
|
||||||
func (e *Executor) executeInternalTool(ctx context.Context, toolName string, command string, args map[string]interface{}) (*mcp.ToolResult, error) {
|
func (e *Executor) executeInternalTool(ctx context.Context, toolName string, command string, args map[string]interface{}) (*mcp.ToolResult, error) {
|
||||||
// 提取内部工具类型(去掉 "internal:" 前缀)
|
// 提取内部工具类型(去掉 "internal:" 前缀)
|
||||||
|
|||||||
@@ -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
|
||||||
+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 "$@"
|
||||||
|
|
||||||
+594
-3
@@ -52,7 +52,7 @@ body {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
/* 主侧边栏与右侧内容之间预留水平间距,避免导航项文字贴到内容边框 */
|
/* 主侧边栏与右侧内容之间预留水平间距,避免导航项文字贴到内容边框 */
|
||||||
column-gap: 12px;
|
column-gap: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 主侧边栏样式 - 紧凑宽度,参考常见后台 200~220px */
|
/* 主侧边栏样式 - 紧凑宽度,参考常见后台 200~220px */
|
||||||
@@ -3447,6 +3447,27 @@ header {
|
|||||||
|
|
||||||
.terminal-container .xterm-viewport {
|
.terminal-container .xterm-viewport {
|
||||||
border-radius: 0;
|
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 {
|
.terminal-error {
|
||||||
@@ -8572,6 +8593,28 @@ header {
|
|||||||
flex-shrink: 0;
|
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 {
|
.webshell-sidebar-header::before {
|
||||||
content: '';
|
content: '';
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -9241,16 +9284,26 @@ header {
|
|||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
margin-bottom: 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 {
|
.webshell-ai-process-toggle {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 8px 12px;
|
padding: 10px 14px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border-color);
|
||||||
border-radius: 6px;
|
border-radius: 10px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.webshell-ai-process-toggle:hover {
|
.webshell-ai-process-toggle:hover {
|
||||||
@@ -9262,10 +9315,43 @@ header {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: max-height 0.3s ease;
|
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 {
|
.webshell-ai-process-block .process-details-content .progress-timeline.expanded {
|
||||||
max-height: 2000px;
|
max-height: 2000px;
|
||||||
overflow-y: auto;
|
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 {
|
.webshell-ai-old-conv {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
@@ -9295,6 +9381,37 @@ header {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
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 {
|
.webshell-ai-messages {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -12764,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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
+71
-10
@@ -24,6 +24,7 @@
|
|||||||
"header": {
|
"header": {
|
||||||
"title": "CyberStrikeAI",
|
"title": "CyberStrikeAI",
|
||||||
"apiDocs": "API Docs",
|
"apiDocs": "API Docs",
|
||||||
|
"github": "GitHub",
|
||||||
"logout": "Sign out",
|
"logout": "Sign out",
|
||||||
"language": "Interface language",
|
"language": "Interface language",
|
||||||
"backToDashboard": "Back to dashboard",
|
"backToDashboard": "Back to dashboard",
|
||||||
@@ -45,17 +46,18 @@
|
|||||||
"tasks": "Tasks",
|
"tasks": "Tasks",
|
||||||
"vulnerabilities": "Vulnerabilities",
|
"vulnerabilities": "Vulnerabilities",
|
||||||
"webshell": "WebShell Management",
|
"webshell": "WebShell Management",
|
||||||
|
"chatFiles": "File Management",
|
||||||
"mcp": "MCP",
|
"mcp": "MCP",
|
||||||
"mcpMonitor": "MCP Monitor",
|
"mcpMonitor": "MCP Monitor",
|
||||||
"mcpManagement": "MCP Management",
|
"mcpManagement": "MCP Management",
|
||||||
"knowledge": "Knowledge",
|
"knowledge": "Knowledge",
|
||||||
"knowledgeRetrievalLogs": "Retrieval history",
|
"knowledgeRetrievalLogs": "Retrieval history",
|
||||||
"knowledgeManagement": "Knowledge management",
|
"knowledgeManagement": "Knowledge Management",
|
||||||
"skills": "Skills",
|
"skills": "Skills",
|
||||||
"skillsMonitor": "Skills monitor",
|
"skillsMonitor": "Skills monitor",
|
||||||
"skillsManagement": "Skills management",
|
"skillsManagement": "Skills Management",
|
||||||
"roles": "Roles",
|
"roles": "Roles",
|
||||||
"rolesManagement": "Roles management",
|
"rolesManagement": "Roles Management",
|
||||||
"settings": "System settings"
|
"settings": "System settings"
|
||||||
},
|
},
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
@@ -185,7 +187,7 @@
|
|||||||
"execFailed": "Execution failed"
|
"execFailed": "Execution failed"
|
||||||
},
|
},
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"title": "Task management",
|
"title": "Task Management",
|
||||||
"stopTask": "Stop task",
|
"stopTask": "Stop task",
|
||||||
"collapseDetail": "Collapse details",
|
"collapseDetail": "Collapse details",
|
||||||
"newTask": "New task",
|
"newTask": "New task",
|
||||||
@@ -323,7 +325,7 @@
|
|||||||
"parseModalApplyRun": "Fill and query"
|
"parseModalApplyRun": "Fill and query"
|
||||||
},
|
},
|
||||||
"vulnerability": {
|
"vulnerability": {
|
||||||
"title": "Vulnerability management",
|
"title": "Vulnerability Management",
|
||||||
"addVuln": "Add vulnerability",
|
"addVuln": "Add vulnerability",
|
||||||
"editVuln": "Edit vulnerability",
|
"editVuln": "Edit vulnerability",
|
||||||
"loadFailed": "Failed to load vulnerabilities",
|
"loadFailed": "Failed to load vulnerabilities",
|
||||||
@@ -393,6 +395,8 @@
|
|||||||
"batchDownload": "Batch download",
|
"batchDownload": "Batch download",
|
||||||
"refresh": "Refresh",
|
"refresh": "Refresh",
|
||||||
"selectAll": "Select all",
|
"selectAll": "Select all",
|
||||||
|
"searchPlaceholder": "Search connections...",
|
||||||
|
"noMatchConnections": "No matching connections",
|
||||||
"breadcrumbHome": "Root"
|
"breadcrumbHome": "Root"
|
||||||
},
|
},
|
||||||
"mcp": {
|
"mcp": {
|
||||||
@@ -485,10 +489,14 @@
|
|||||||
"title": "System settings",
|
"title": "System settings",
|
||||||
"nav": {
|
"nav": {
|
||||||
"basic": "Basic",
|
"basic": "Basic",
|
||||||
|
"knowledge": "Knowledge base",
|
||||||
"robots": "Bots",
|
"robots": "Bots",
|
||||||
"terminal": "Terminal",
|
"terminal": "Terminal",
|
||||||
"security": "Security"
|
"security": "Security"
|
||||||
},
|
},
|
||||||
|
"knowledge": {
|
||||||
|
"title": "Knowledge base"
|
||||||
|
},
|
||||||
"robots": {
|
"robots": {
|
||||||
"title": "Bot settings",
|
"title": "Bot settings",
|
||||||
"description": "Configure WeCom, DingTalk and Lark bots so you can chat with CyberStrikeAI on your phone without opening the web UI.",
|
"description": "Configure WeCom, DingTalk and Lark bots so you can chat with CyberStrikeAI on your phone without opening the web UI.",
|
||||||
@@ -549,7 +557,7 @@
|
|||||||
"loggedOut": "Signed out"
|
"loggedOut": "Signed out"
|
||||||
},
|
},
|
||||||
"knowledge": {
|
"knowledge": {
|
||||||
"title": "Knowledge management",
|
"title": "Knowledge Management",
|
||||||
"retrievalLogs": "Retrieval history",
|
"retrievalLogs": "Retrieval history",
|
||||||
"totalItems": "Total items",
|
"totalItems": "Total items",
|
||||||
"categories": "Categories",
|
"categories": "Categories",
|
||||||
@@ -562,7 +570,7 @@
|
|||||||
"goToSettings": "Go to settings"
|
"goToSettings": "Go to settings"
|
||||||
},
|
},
|
||||||
"roles": {
|
"roles": {
|
||||||
"title": "Role management",
|
"title": "Role Management",
|
||||||
"createRole": "Create role",
|
"createRole": "Create role",
|
||||||
"searchPlaceholder": "Search roles...",
|
"searchPlaceholder": "Search roles...",
|
||||||
"deleteConfirm": "Delete this role?",
|
"deleteConfirm": "Delete this role?",
|
||||||
@@ -576,7 +584,7 @@
|
|||||||
"noDescriptionShort": "No description"
|
"noDescriptionShort": "No description"
|
||||||
},
|
},
|
||||||
"skills": {
|
"skills": {
|
||||||
"title": "Skills management",
|
"title": "Skills Management",
|
||||||
"monitorTitle": "Skills monitor",
|
"monitorTitle": "Skills monitor",
|
||||||
"createSkill": "Create Skill",
|
"createSkill": "Create Skill",
|
||||||
"callStats": "Call stats",
|
"callStats": "Call stats",
|
||||||
@@ -991,6 +999,59 @@
|
|||||||
"exportXlsxTitle": "Export results as Excel",
|
"exportXlsxTitle": "Export results as Excel",
|
||||||
"batchScanTitle": "Create batch task queue from selected rows"
|
"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": {
|
"vulnerabilityPage": {
|
||||||
"statTotal": "Total",
|
"statTotal": "Total",
|
||||||
"filter": "Filter",
|
"filter": "Filter",
|
||||||
@@ -1358,10 +1419,10 @@
|
|||||||
"userPromptHint": "This prompt is appended before user message to guide AI. It does not change system prompt.",
|
"userPromptHint": "This prompt is appended before user message to guide AI. It does not change system prompt.",
|
||||||
"relatedTools": "Related tools (optional)",
|
"relatedTools": "Related tools (optional)",
|
||||||
"defaultRoleToolsTitle": "Default role uses all tools",
|
"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...",
|
"searchToolsPlaceholder": "Search tools...",
|
||||||
"loadingTools": "Loading 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)",
|
"relatedSkills": "Related Skills (optional)",
|
||||||
"searchSkillsPlaceholder": "Search skill...",
|
"searchSkillsPlaceholder": "Search skill...",
|
||||||
"loadingSkills": "Loading skills...",
|
"loadingSkills": "Loading skills...",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"header": {
|
"header": {
|
||||||
"title": "CyberStrikeAI",
|
"title": "CyberStrikeAI",
|
||||||
"apiDocs": "API 文档",
|
"apiDocs": "API 文档",
|
||||||
|
"github": "GitHub",
|
||||||
"logout": "退出登录",
|
"logout": "退出登录",
|
||||||
"language": "界面语言",
|
"language": "界面语言",
|
||||||
"backToDashboard": "返回仪表盘",
|
"backToDashboard": "返回仪表盘",
|
||||||
@@ -45,6 +46,7 @@
|
|||||||
"tasks": "任务管理",
|
"tasks": "任务管理",
|
||||||
"vulnerabilities": "漏洞管理",
|
"vulnerabilities": "漏洞管理",
|
||||||
"webshell": "WebShell管理",
|
"webshell": "WebShell管理",
|
||||||
|
"chatFiles": "文件管理",
|
||||||
"mcp": "MCP",
|
"mcp": "MCP",
|
||||||
"mcpMonitor": "MCP状态监控",
|
"mcpMonitor": "MCP状态监控",
|
||||||
"mcpManagement": "MCP管理",
|
"mcpManagement": "MCP管理",
|
||||||
@@ -393,6 +395,8 @@
|
|||||||
"batchDownload": "批量下载",
|
"batchDownload": "批量下载",
|
||||||
"refresh": "刷新",
|
"refresh": "刷新",
|
||||||
"selectAll": "全选",
|
"selectAll": "全选",
|
||||||
|
"searchPlaceholder": "搜索连接...",
|
||||||
|
"noMatchConnections": "暂无匹配连接",
|
||||||
"breadcrumbHome": "根"
|
"breadcrumbHome": "根"
|
||||||
},
|
},
|
||||||
"mcp": {
|
"mcp": {
|
||||||
@@ -485,10 +489,14 @@
|
|||||||
"title": "系统设置",
|
"title": "系统设置",
|
||||||
"nav": {
|
"nav": {
|
||||||
"basic": "基本设置",
|
"basic": "基本设置",
|
||||||
|
"knowledge": "知识库",
|
||||||
"robots": "机器人设置",
|
"robots": "机器人设置",
|
||||||
"terminal": "终端",
|
"terminal": "终端",
|
||||||
"security": "安全设置"
|
"security": "安全设置"
|
||||||
},
|
},
|
||||||
|
"knowledge": {
|
||||||
|
"title": "知识库设置"
|
||||||
|
},
|
||||||
"robots": {
|
"robots": {
|
||||||
"title": "机器人设置",
|
"title": "机器人设置",
|
||||||
"description": "配置企业微信、钉钉、飞书等机器人,在手机端直接与 CyberStrikeAI 对话,无需在服务器上打开网页。",
|
"description": "配置企业微信、钉钉、飞书等机器人,在手机端直接与 CyberStrikeAI 对话,无需在服务器上打开网页。",
|
||||||
@@ -991,6 +999,59 @@
|
|||||||
"exportXlsxTitle": "导出当前结果为 Excel",
|
"exportXlsxTitle": "导出当前结果为 Excel",
|
||||||
"batchScanTitle": "将所选行创建为批量任务队列"
|
"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": {
|
"vulnerabilityPage": {
|
||||||
"statTotal": "总漏洞数",
|
"statTotal": "总漏洞数",
|
||||||
"filter": "筛选",
|
"filter": "筛选",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+314
-22
@@ -67,6 +67,75 @@ if (typeof window !== 'undefined') {
|
|||||||
// 存储工具调用ID到DOM元素的映射,用于更新执行状态
|
// 存储工具调用ID到DOM元素的映射,用于更新执行状态
|
||||||
const toolCallStatusMap = new Map();
|
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 = {
|
const conversationExecutionTracker = {
|
||||||
activeConversations: new Set(),
|
activeConversations: new Set(),
|
||||||
update(tasks = []) {
|
update(tasks = []) {
|
||||||
@@ -543,7 +612,77 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
});
|
});
|
||||||
break;
|
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':
|
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', {
|
addTimelineItem(timeline, 'thinking', {
|
||||||
title: '🤔 ' + (typeof window.t === 'function' ? window.t('chat.aiThinking') : 'AI思考'),
|
title: '🤔 ' + (typeof window.t === 'function' ? window.t('chat.aiThinking') : 'AI思考'),
|
||||||
message: event.message,
|
message: event.message,
|
||||||
@@ -584,6 +723,55 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
updateToolCallStatus(toolCallId, 'running');
|
updateToolCallStatus(toolCallId, 'running');
|
||||||
}
|
}
|
||||||
break;
|
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':
|
case 'tool_result':
|
||||||
const resultInfo = event.data || {};
|
const resultInfo = event.data || {};
|
||||||
@@ -592,6 +780,39 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
const statusIcon = success ? '✅' : '❌';
|
const statusIcon = success ? '✅' : '❌';
|
||||||
const resultToolCallId = resultInfo.toolCallId || null;
|
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) + ' 执行失败');
|
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)) {
|
if (resultToolCallId && toolCallStatusMap.has(resultToolCallId)) {
|
||||||
updateToolCallStatus(resultToolCallId, success ? 'completed' : 'failed');
|
updateToolCallStatus(resultToolCallId, success ? 'completed' : 'failed');
|
||||||
toolCallStatusMap.delete(resultToolCallId);
|
toolCallStatusMap.delete(resultToolCallId);
|
||||||
@@ -683,47 +904,108 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
loadActiveTasks();
|
loadActiveTasks();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'response':
|
case 'response_start': {
|
||||||
// 在更新之前,先获取任务对应的原始对话ID
|
|
||||||
const responseTaskState = progressTaskState.get(progressId);
|
const responseTaskState = progressTaskState.get(progressId);
|
||||||
const responseOriginalConversationId = responseTaskState?.conversationId;
|
const responseOriginalConversationId = responseTaskState?.conversationId;
|
||||||
|
|
||||||
// 先添加助手回复
|
|
||||||
const responseData = event.data || {};
|
const responseData = event.data || {};
|
||||||
const mcpIds = responseData.mcpExecutionIds || [];
|
const mcpIds = responseData.mcpExecutionIds || [];
|
||||||
setMcpIds(mcpIds);
|
setMcpIds(mcpIds);
|
||||||
|
|
||||||
// 更新对话ID
|
|
||||||
if (responseData.conversationId) {
|
if (responseData.conversationId) {
|
||||||
// 如果用户已经开始了新对话(currentConversationId 为 null),
|
// 如果用户已经开始了新对话(currentConversationId 为 null),且这个事件来自旧对话,则忽略
|
||||||
// 且这个 response 事件来自旧对话,就不更新 currentConversationId 也不添加消息
|
|
||||||
if (currentConversationId === null && responseOriginalConversationId !== null) {
|
if (currentConversationId === null && responseOriginalConversationId !== null) {
|
||||||
// 用户已经开始了新对话,忽略旧对话的 response 事件
|
|
||||||
// 但仍然更新任务状态,以便正确显示任务信息
|
|
||||||
updateProgressConversation(progressId, responseData.conversationId);
|
updateProgressConversation(progressId, responseData.conversationId);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
currentConversationId = responseData.conversationId;
|
currentConversationId = responseData.conversationId;
|
||||||
updateActiveConversation();
|
updateActiveConversation();
|
||||||
addAttackChainButton(currentConversationId);
|
addAttackChainButton(currentConversationId);
|
||||||
updateProgressConversation(progressId, responseData.conversationId);
|
updateProgressConversation(progressId, responseData.conversationId);
|
||||||
loadActiveTasks();
|
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);
|
setAssistantId(assistantId);
|
||||||
|
responseStreamStateByProgressId.set(progressId, { assistantId, buffer: '' });
|
||||||
// 将进度详情集成到工具调用区域
|
break;
|
||||||
integrateProgressToMCPSection(progressId, assistantId);
|
}
|
||||||
|
|
||||||
// 延迟自动折叠详情(3秒后)
|
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(() => {
|
setTimeout(() => {
|
||||||
collapseAllProgressDetails(assistantId, progressId);
|
collapseAllProgressDetails(assistantIdFinal, progressId);
|
||||||
}, 3000);
|
}, 3000);
|
||||||
|
|
||||||
// 延迟刷新对话列表,确保助手消息已保存,updated_at已更新
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
loadConversations();
|
loadConversations();
|
||||||
}, 200);
|
}, 200);
|
||||||
@@ -802,6 +1084,16 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'done':
|
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`);
|
const doneTitle = document.querySelector(`#${progressId} .progress-title`);
|
||||||
if (doneTitle) {
|
if (doneTitle) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ function initRouter() {
|
|||||||
if (hash) {
|
if (hash) {
|
||||||
const hashParts = hash.split('?');
|
const hashParts = hash.split('?');
|
||||||
const pageId = hashParts[0];
|
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);
|
switchPage(pageId);
|
||||||
|
|
||||||
// 如果是chat页面且带有conversation参数,加载对应对话
|
// 如果是chat页面且带有conversation参数,加载对应对话
|
||||||
@@ -299,6 +299,11 @@ function initPage(pageId) {
|
|||||||
initWebshellPage();
|
initWebshellPage();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case 'chat-files':
|
||||||
|
if (typeof initChatFilesPage === 'function') {
|
||||||
|
initChatFilesPage();
|
||||||
|
}
|
||||||
|
break;
|
||||||
case 'settings':
|
case 'settings':
|
||||||
// 初始化设置页面(不需要加载工具列表)
|
// 初始化设置页面(不需要加载工具列表)
|
||||||
if (typeof loadConfig === 'function') {
|
if (typeof loadConfig === 'function') {
|
||||||
@@ -368,7 +373,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
const hashParts = hash.split('?');
|
const hashParts = hash.split('?');
|
||||||
const pageId = hashParts[0];
|
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);
|
switchPage(pageId);
|
||||||
|
|
||||||
// 如果是chat页面且带有conversation参数,加载对应对话
|
// 如果是chat页面且带有conversation参数,加载对应对话
|
||||||
|
|||||||
@@ -99,6 +99,8 @@ function wsT(key) {
|
|||||||
'webshell.refresh': '刷新',
|
'webshell.refresh': '刷新',
|
||||||
'webshell.selectAll': '全选',
|
'webshell.selectAll': '全选',
|
||||||
'webshell.breadcrumbHome': '根',
|
'webshell.breadcrumbHome': '根',
|
||||||
|
'webshell.searchPlaceholder': '搜索连接...',
|
||||||
|
'webshell.noMatchConnections': '暂无匹配连接',
|
||||||
'common.delete': '删除',
|
'common.delete': '删除',
|
||||||
'common.refresh': '刷新'
|
'common.refresh': '刷新'
|
||||||
};
|
};
|
||||||
@@ -137,6 +139,16 @@ function initWebshellPage() {
|
|||||||
renderWebshellList();
|
renderWebshellList();
|
||||||
applyWebshellSidebarWidth();
|
applyWebshellSidebarWidth();
|
||||||
initWebshellSidebarResize();
|
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');
|
const workspace = document.getElementById('webshell-workspace');
|
||||||
if (workspace) {
|
if (workspace) {
|
||||||
workspace.innerHTML = '<div class="webshell-workspace-placeholder" data-i18n="webshell.selectOrAdd">' + (wsT('webshell.selectOrAdd')) + '</div>';
|
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');
|
const listEl = document.getElementById('webshell-list');
|
||||||
if (!listEl) return;
|
if (!listEl) return;
|
||||||
|
|
||||||
|
const searchEl = document.getElementById('webshell-conn-search');
|
||||||
|
const searchTerm = (searchEl && typeof searchEl.value === 'string' ? searchEl.value : '').trim().toLowerCase();
|
||||||
|
|
||||||
if (!webshellConnections.length) {
|
if (!webshellConnections.length) {
|
||||||
listEl.innerHTML = '<div class="webshell-empty" data-i18n="webshell.noConnections">' + (wsT('webshell.noConnections')) + '</div>';
|
listEl.innerHTML = '<div class="webshell-empty" data-i18n="webshell.noConnections">' + (wsT('webshell.noConnections')) + '</div>';
|
||||||
return;
|
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 remark = (conn.remark || conn.url || '').replace(/</g, '<').replace(/>/g, '>');
|
||||||
const url = (conn.url || '').replace(/</g, '<').replace(/>/g, '>');
|
const url = (conn.url || '').replace(/</g, '<').replace(/>/g, '>');
|
||||||
const urlTitle = (conn.url || '').replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
const urlTitle = (conn.url || '').replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
||||||
@@ -797,10 +826,25 @@ function runWebshellAiSend(conn, inputEl, sendBtn, messagesContainer) {
|
|||||||
el.classList.toggle('active', el.dataset.convId === convId);
|
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') {
|
} else if (eventData.type === 'response') {
|
||||||
var text = (eventData.message != null && eventData.message !== '') ? eventData.message : (eventData.data && typeof eventData.data === 'string' ? eventData.data : '');
|
var text = (eventData.message != null && eventData.message !== '') ? eventData.message : (eventData.data && typeof eventData.data === 'string' ? eventData.data : '');
|
||||||
if (text) {
|
if (text) {
|
||||||
streamingTarget += text;
|
// response 为最终完整内容:避免与增量重复拼接
|
||||||
|
streamingTarget = String(text);
|
||||||
webshellStreamingTypingId += 1;
|
webshellStreamingTypingId += 1;
|
||||||
streamingTypingId = webshellStreamingTypingId;
|
streamingTypingId = webshellStreamingTypingId;
|
||||||
runWebshellAiStreamingTyping(assistantDiv, streamingTarget, streamingTypingId, messagesContainer);
|
runWebshellAiStreamingTyping(assistantDiv, streamingTarget, streamingTypingId, messagesContainer);
|
||||||
@@ -1672,6 +1716,14 @@ function refreshWebshellUIOnLanguageChange() {
|
|||||||
if (fileListEl && webshellCurrentConn && pathInput) {
|
if (fileListEl && webshellCurrentConn && pathInput) {
|
||||||
webshellFileListDir(webshellCurrentConn, pathInput.value.trim() || '.');
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+117
-5
@@ -47,6 +47,12 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span data-i18n="header.apiDocs">API 文档</span>
|
<span data-i18n="header.apiDocs">API 文档</span>
|
||||||
</button>
|
</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">
|
<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="界面语言">
|
<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>
|
<span class="lang-switcher-icon">🌐</span>
|
||||||
@@ -144,6 +150,14 @@
|
|||||||
<span data-i18n="nav.webshell">WebShell管理</span>
|
<span data-i18n="nav.webshell">WebShell管理</span>
|
||||||
</div>
|
</div>
|
||||||
</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 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">
|
<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">
|
<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 class="webshell-layout">
|
||||||
<div id="webshell-sidebar" class="webshell-sidebar">
|
<div id="webshell-sidebar" class="webshell-sidebar">
|
||||||
<div class="webshell-sidebar-header" data-i18n="webshell.connections">连接列表</div>
|
<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 id="webshell-list" class="webshell-list">
|
||||||
<div class="webshell-empty" data-i18n="webshell.noConnections">暂无连接,请点击「添加连接」</div>
|
<div class="webshell-empty" data-i18n="webshell.noConnections">暂无连接,请点击「添加连接」</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -986,6 +1008,44 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 id="page-tasks" class="page">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
@@ -1128,6 +1188,9 @@
|
|||||||
<div class="settings-nav-item active" data-section="basic" onclick="switchSettingsSection('basic')">
|
<div class="settings-nav-item active" data-section="basic" onclick="switchSettingsSection('basic')">
|
||||||
<span data-i18n="settings.nav.basic">基本设置</span>
|
<span data-i18n="settings.nav.basic">基本设置</span>
|
||||||
</div>
|
</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')">
|
<div class="settings-nav-item" data-section="robots" onclick="switchSettingsSection('robots')">
|
||||||
<span data-i18n="settings.nav.robots">机器人设置</span>
|
<span data-i18n="settings.nav.robots">机器人设置</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1199,7 +1262,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="settings-subsection">
|
||||||
<h4 data-i18n="settingsBasic.knowledgeConfig">知识库配置</h4>
|
<h4 data-i18n="settingsBasic.knowledgeConfig">知识库配置</h4>
|
||||||
<div class="settings-form">
|
<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" />
|
<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>
|
<small class="form-hint" data-i18n="settingsBasic.knowledgeBasePathHint">相对于配置文件所在目录的路径</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-subsection-header">
|
<div class="settings-subsection-header">
|
||||||
<h5 data-i18n="settingsBasic.embeddingConfig">嵌入模型配置</h5>
|
<h5 data-i18n="settingsBasic.embeddingConfig">嵌入模型配置</h5>
|
||||||
</div>
|
</div>
|
||||||
@@ -1239,7 +1312,7 @@
|
|||||||
<label for="knowledge-embedding-model" data-i18n="settingsBasic.modelName">模型名称</label>
|
<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" />
|
<input type="text" id="knowledge-embedding-model" data-i18n="settingsBasic.embeddingModelPlaceholder" data-i18n-attr="placeholder" placeholder="text-embedding-v4" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-subsection-header">
|
<div class="settings-subsection-header">
|
||||||
<h5 data-i18n="settingsBasic.retrievalConfig">检索配置</h5>
|
<h5 data-i18n="settingsBasic.retrievalConfig">检索配置</h5>
|
||||||
</div>
|
</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" />
|
<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>
|
<small class="form-hint" data-i18n="settingsBasic.hybridHint">向量检索的权重(0-1),1.0表示纯向量检索,0.0表示纯关键词检索</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="settings-subsection-header">
|
<div class="settings-subsection-header">
|
||||||
<h5 data-i18n="settingsBasic.indexConfig">索引配置</h5>
|
<h5 data-i18n="settingsBasic.indexConfig">索引配置</h5>
|
||||||
</div>
|
</div>
|
||||||
@@ -1296,7 +1369,9 @@
|
|||||||
<label for="knowledge-indexing-retry-delay-ms" data-i18n="settingsBasic.retryDelay">重试间隔(毫秒)</label>
|
<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" />
|
<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>
|
<small class="form-hint" data-i18n="settingsBasic.retryDelayHint">重试间隔毫秒数(默认 1000),每次重试会递增延迟</small>
|
||||||
</div> </div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="settings-actions">
|
<div class="settings-actions">
|
||||||
<button class="btn-primary" onclick="applySettings()" data-i18n="settings.apply.button">应用配置</button>
|
<button class="btn-primary" onclick="applySettings()" data-i18n="settings.apply.button">应用配置</button>
|
||||||
@@ -1707,6 +1782,42 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Marked.js for Markdown parsing -->
|
||||||
<script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/marked@11.1.1/marked.min.js"></script>
|
||||||
<!-- DOMPurify for HTML sanitization to prevent XSS -->
|
<!-- 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/skills.js"></script>
|
||||||
<script src="/static/js/vulnerability.js?v=4"></script>
|
<script src="/static/js/vulnerability.js?v=4"></script>
|
||||||
<script src="/static/js/webshell.js"></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/tasks.js"></script>
|
||||||
<script src="/static/js/roles.js"></script>
|
<script src="/static/js/roles.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user