Compare commits

...

16 Commits

Author SHA1 Message Date
公明 0183d9f15f Add files via upload 2026-04-08 18:14:22 +08:00
公明 7d7207c12f Update config.yaml 2026-04-08 16:58:20 +08:00
公明 9eb47d96f5 Add files via upload 2026-04-08 00:18:07 +08:00
公明 cf1c9c199c Update server.go 2026-04-07 11:51:35 +08:00
公明 ce5f20c11e Add files via upload 2026-04-04 15:05:38 +08:00
公明 d87bc09a2e Update config.yaml 2026-04-04 15:00:13 +08:00
公明 6cd89414f9 Add files via upload 2026-04-03 23:27:28 +08:00
公明 e538a744c3 Add files via upload 2026-04-03 23:23:58 +08:00
公明 dd4d534e24 Add files via upload 2026-04-03 23:06:43 +08:00
公明 f1a31a459c Add files via upload 2026-04-03 22:57:38 +08:00
公明 4fd083ff37 Add files via upload 2026-04-03 22:55:30 +08:00
公明 acef729800 Update version to v1.4.8 in config.yaml 2026-04-03 22:19:47 +08:00
公明 e7609c5fc4 Add files via upload 2026-04-03 22:09:23 +08:00
公明 2b6d0486c8 Add files via upload 2026-04-03 21:35:22 +08:00
公明 d5eb4ce119 Add files via upload 2026-04-03 21:29:55 +08:00
公明 92a8339267 Add files via upload 2026-04-03 21:29:24 +08:00
26 changed files with 935 additions and 137 deletions
+11
View File
@@ -16,6 +16,17 @@
</details>
<details>
<summary><strong>Sponsorship</strong> (click to expand)</summary>
If CyberStrikeAI helps you, you can support the project via **WeChat Pay** or **Alipay**:
<div align="center">
<img src="./images/sponsor-wechat-alipay-qr.jpg" alt="WeChat Pay and Alipay sponsorship QR codes" width="480">
</div>
</details>
CyberStrikeAI is an **AI-native security testing platform** built in Go. It integrates 100+ security tools, an intelligent orchestration engine, role-based testing with predefined security roles, a skills system with specialized testing skills, and comprehensive lifecycle management capabilities. Through native MCP protocol and AI agents, it enables end-to-end automation from conversational commands to vulnerability discovery, attack-chain analysis, knowledge retrieval, and result visualization—delivering an auditable, traceable, and collaborative testing environment for security teams.
+11
View File
@@ -15,6 +15,17 @@
</details>
<details>
<summary><strong>赞助</strong>(点击展开)</summary>
若 CyberStrikeAI 对您有帮助,可通过 **微信支付****支付宝** 赞助项目:
<div align="center">
<img src="./images/sponsor-wechat-alipay-qr.jpg" alt="微信与支付宝赞助二维码" width="480">
</div>
</details>
CyberStrikeAI 是一款 **AI 原生安全测试平台**,基于 Go 构建,集成了 100+ 安全工具、智能编排引擎、角色化测试与预设安全测试角色、Skills 技能系统与专业测试技能,以及完整的测试生命周期管理能力。通过原生 MCP 协议与 AI 智能体,支持从对话指令到漏洞发现、攻击链分析、知识检索与结果可视化的全流程自动化,为安全团队提供可审计、可追溯、可协作的专业测试环境。
+1 -1
View File
@@ -10,7 +10,7 @@
# ============================================
# 前端显示的版本号(可选,不填则显示默认版本)
version: "v1.4.7"
version: "v1.4.10"
# 服务器配置
server:
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 182 KiB

+1
View File
@@ -664,6 +664,7 @@ func setupRoutes(
protected.GET("/messages/:id/process-details", conversationHandler.GetMessageProcessDetails)
protected.PUT("/conversations/:id", conversationHandler.UpdateConversation)
protected.DELETE("/conversations/:id", conversationHandler.DeleteConversation)
protected.POST("/conversations/:id/delete-turn", conversationHandler.DeleteConversationTurn)
protected.PUT("/conversations/:id/pinned", groupHandler.UpdateConversationPinned)
// 对话分组
+127 -1
View File
@@ -97,7 +97,8 @@ func (b *Builder) BuildChainFromConversation(ctx context.Context, conversationID
return &Chain{Nodes: []Node{}, Edges: []Edge{}}, nil
}
// 检查是否有实际的工具执行(通过检查assistant消息的mcp_execution_ids
// 检查是否有实际的工具执行assistantmcp_execution_ids,或过程详情中的 tool_call/tool_result
//(多代理下若 MCP 未返回 execution_idIDs 可能为空,但工具已通过 Eino 执行并写入 process_details
hasToolExecutions := false
for i := len(messages) - 1; i >= 0; i-- {
if strings.EqualFold(messages[i].Role, "assistant") {
@@ -107,6 +108,13 @@ func (b *Builder) BuildChainFromConversation(ctx context.Context, conversationID
}
}
}
if !hasToolExecutions {
if pdOK, err := b.db.ConversationHasToolProcessDetails(conversationID); err != nil {
b.logger.Warn("查询过程详情判定工具执行失败", zap.Error(err))
} else if pdOK {
hasToolExecutions = true
}
}
// 检查任务是否被取消(通过检查最后一条assistant消息内容或process_details
taskCancelled := false
@@ -204,6 +212,37 @@ func (b *Builder) BuildChainFromConversation(ctx context.Context, conversationID
}
}
// 多代理:保存的 last_react_input 可能仅为首轮用户消息,不含工具轨迹;补充最后一轮助手的过程详情(与单代理「最后一轮 ReAct」对齐)
hasMCPOnAssistant := false
var lastAssistantID string
for i := len(messages) - 1; i >= 0; i-- {
if strings.EqualFold(messages[i].Role, "assistant") {
lastAssistantID = messages[i].ID
if len(messages[i].MCPExecutionIDs) > 0 {
hasMCPOnAssistant = true
}
break
}
}
if lastAssistantID != "" {
pdHasTools, _ := b.db.ConversationHasToolProcessDetails(conversationID)
if pdHasTools && !(hasMCPOnAssistant && reactInputContainsToolTrace(reactInputJSON)) {
detailsMap, err := b.db.GetProcessDetailsByConversation(conversationID)
if err != nil {
b.logger.Warn("加载过程详情用于攻击链失败", zap.Error(err))
} else if dets := detailsMap[lastAssistantID]; len(dets) > 0 {
extra := b.formatProcessDetailsForAttackChain(dets)
if strings.TrimSpace(extra) != "" {
reactInputFinal = reactInputFinal + "\n\n## 执行过程与工具记录(含多代理编排与子任务)\n\n" + extra
b.logger.Info("攻击链输入已补充过程详情",
zap.String("conversationId", conversationID),
zap.String("messageId", lastAssistantID),
zap.Int("detailEvents", len(dets)))
}
}
}
}
// 3. 构建简化的prompt,一次性传递给大模型
prompt := b.buildSimplePrompt(reactInputFinal, modelOutput)
// fmt.Println(prompt)
@@ -240,6 +279,93 @@ func (b *Builder) BuildChainFromConversation(ctx context.Context, conversationID
return chainData, nil
}
// reactInputContainsToolTrace 判断保存的 ReAct JSON 是否包含可解析的工具调用轨迹(单代理完整保存时为 true)。
func reactInputContainsToolTrace(reactInputJSON string) bool {
s := strings.TrimSpace(reactInputJSON)
if s == "" {
return false
}
return strings.Contains(s, "tool_calls") ||
strings.Contains(s, "tool_call_id") ||
strings.Contains(s, `"role":"tool"`) ||
strings.Contains(s, `"role": "tool"`)
}
// formatProcessDetailsForAttackChain 将最后一轮助手的过程详情格式化为攻击链分析的输入(覆盖多代理下 last_react_input 不完整的情况)。
func (b *Builder) formatProcessDetailsForAttackChain(details []database.ProcessDetail) string {
if len(details) == 0 {
return ""
}
var sb strings.Builder
for _, d := range details {
// 目标:以主 agent(编排器)视角输出整轮迭代
// - 保留:编排器工具调用/结果、对子代理的 task 调度、子代理最终回复(不含推理)
// - 丢弃:thinking/planning/progress 等噪声、子代理的工具细节与推理过程
if d.EventType == "progress" || d.EventType == "thinking" || d.EventType == "planning" {
continue
}
// 解析 dataJSON string),用于识别 einoRole / toolName 等
var dataMap map[string]interface{}
if strings.TrimSpace(d.Data) != "" {
_ = json.Unmarshal([]byte(d.Data), &dataMap)
}
einoRole := ""
if v, ok := dataMap["einoRole"]; ok {
einoRole = strings.ToLower(strings.TrimSpace(fmt.Sprint(v)))
}
toolName := ""
if v, ok := dataMap["toolName"]; ok {
toolName = strings.TrimSpace(fmt.Sprint(v))
}
// 1) 编排器的工具调用/结果:保留(这是“主 agent 调了什么工具”)
if (d.EventType == "tool_call" || d.EventType == "tool_result" || d.EventType == "tool_calls_detected" || d.EventType == "iteration" || d.EventType == "eino_recovery") && einoRole == "orchestrator" {
sb.WriteString("[")
sb.WriteString(d.EventType)
sb.WriteString("] ")
sb.WriteString(strings.TrimSpace(d.Message))
sb.WriteString("\n")
if strings.TrimSpace(d.Data) != "" {
sb.WriteString(d.Data)
sb.WriteString("\n")
}
sb.WriteString("\n")
continue
}
// 2) 子代理调度:tool_call(toolName=="task") 代表编排器把子任务派发出去;保留(只需任务,不要子代理推理)
if d.EventType == "tool_call" && strings.EqualFold(toolName, "task") {
sb.WriteString("[dispatch_subagent_task] ")
sb.WriteString(strings.TrimSpace(d.Message))
sb.WriteString("\n")
if strings.TrimSpace(d.Data) != "" {
sb.WriteString(d.Data)
sb.WriteString("\n")
}
sb.WriteString("\n")
continue
}
// 3) 子代理最终回复:保留(只保留最终输出,不保留分析过程)
if d.EventType == "eino_agent_reply" && einoRole == "sub" {
sb.WriteString("[subagent_final_reply] ")
sb.WriteString(strings.TrimSpace(d.Message))
sb.WriteString("\n")
// data 里含 einoAgent 等元信息,保留有助于追踪“哪个子代理说的”
if strings.TrimSpace(d.Data) != "" {
sb.WriteString(d.Data)
sb.WriteString("\n")
}
sb.WriteString("\n")
continue
}
// 其他事件默认丢弃,避免把子代理工具细节/推理塞进 prompt,偏离“主 agent 一轮迭代”的视角。
}
return strings.TrimSpace(sb.String())
}
// buildReActInput 构建最后一轮ReAct的输入(历史消息+当前用户输入)
func (b *Builder) buildReActInput(messages []database.Message) string {
var builder strings.Builder
+110
View File
@@ -4,6 +4,7 @@ import (
"database/sql"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/google/uuid"
@@ -457,6 +458,19 @@ func (db *DB) GetReActData(conversationID string) (reactInput, reactOutput strin
return reactInput, reactOutput, nil
}
// ConversationHasToolProcessDetails 对话是否存在已落库的工具调用/结果(用于多代理等场景下 MCP execution id 未汇总时的攻击链判定)。
func (db *DB) ConversationHasToolProcessDetails(conversationID string) (bool, error) {
var n int
err := db.QueryRow(
`SELECT COUNT(*) FROM process_details WHERE conversation_id = ? AND event_type IN ('tool_call', 'tool_result')`,
conversationID,
).Scan(&n)
if err != nil {
return false, fmt.Errorf("查询过程详情失败: %w", err)
}
return n > 0, nil
}
// AddMessage 添加消息
func (db *DB) AddMessage(conversationID, role, content string, mcpExecutionIDs []string) (*Message, error) {
id := uuid.New().String()
@@ -540,6 +554,102 @@ func (db *DB) GetMessages(conversationID string) ([]Message, error) {
return messages, nil
}
// turnSliceRange 根据任意一条消息 ID 定位「一轮对话」在 msgs 中的 [start, end) 下标区间(msgs 须已按时间升序,与 GetMessages 一致)。
// 一轮 = 从某条 user 消息起,至下一条 user 之前(含中间所有 assistant)。
func turnSliceRange(msgs []Message, anchorID string) (start, end int, err error) {
idx := -1
for i := range msgs {
if msgs[i].ID == anchorID {
idx = i
break
}
}
if idx < 0 {
return 0, 0, fmt.Errorf("message not found")
}
start = idx
for start > 0 && msgs[start].Role != "user" {
start--
}
if start < len(msgs) && msgs[start].Role != "user" {
start = 0
}
end = len(msgs)
for i := start + 1; i < len(msgs); i++ {
if msgs[i].Role == "user" {
end = i
break
}
}
return start, end, nil
}
// DeleteConversationTurn 删除锚点所在轮次的全部消息(用户提问 + 该轮助手回复等),并清空 last_react_*,避免与消息表不一致。
func (db *DB) DeleteConversationTurn(conversationID, anchorMessageID string) (deletedIDs []string, err error) {
msgs, err := db.GetMessages(conversationID)
if err != nil {
return nil, err
}
start, end, err := turnSliceRange(msgs, anchorMessageID)
if err != nil {
return nil, err
}
if start >= end {
return nil, fmt.Errorf("empty turn range")
}
deletedIDs = make([]string, 0, end-start)
for i := start; i < end; i++ {
deletedIDs = append(deletedIDs, msgs[i].ID)
}
tx, err := db.Begin()
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
ph := strings.Repeat("?,", len(deletedIDs))
ph = ph[:len(ph)-1]
args := make([]interface{}, 0, 1+len(deletedIDs))
args = append(args, conversationID)
for _, id := range deletedIDs {
args = append(args, id)
}
res, err := tx.Exec(
"DELETE FROM messages WHERE conversation_id = ? AND id IN ("+ph+")",
args...,
)
if err != nil {
return nil, fmt.Errorf("delete messages: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return nil, err
}
if int(n) != len(deletedIDs) {
return nil, fmt.Errorf("deleted count mismatch")
}
_, err = tx.Exec(
`UPDATE conversations SET last_react_input = NULL, last_react_output = NULL, updated_at = ? WHERE id = ?`,
time.Now(), conversationID,
)
if err != nil {
return nil, fmt.Errorf("clear react data: %w", err)
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit: %w", err)
}
db.logger.Info("conversation turn deleted",
zap.String("conversationId", conversationID),
zap.Strings("deletedMessageIds", deletedIDs),
zap.Int("count", len(deletedIDs)),
)
return deletedIDs, nil
}
// ProcessDetail 过程详情事件
type ProcessDetail struct {
ID string `json:"id"`
@@ -0,0 +1,39 @@
package database
import (
"testing"
)
func TestTurnSliceRange(t *testing.T) {
mk := func(id, role string) Message {
return Message{ID: id, Role: role}
}
msgs := []Message{
mk("u1", "user"),
mk("a1", "assistant"),
mk("u2", "user"),
mk("a2", "assistant"),
}
cases := []struct {
anchor string
start int
end int
}{
{"u1", 0, 2},
{"a1", 0, 2},
{"u2", 2, 4},
{"a2", 2, 4},
}
for _, tc := range cases {
s, e, err := turnSliceRange(msgs, tc.anchor)
if err != nil {
t.Fatalf("anchor %s: %v", tc.anchor, err)
}
if s != tc.start || e != tc.end {
t.Fatalf("anchor %s: got [%d,%d) want [%d,%d)", tc.anchor, s, e, tc.start, tc.end)
}
}
if _, _, err := turnSliceRange(msgs, "nope"); err == nil {
t.Fatal("expected error for missing id")
}
}
+7 -1
View File
@@ -108,7 +108,13 @@ func runMCPToolInvocation(
var args map[string]interface{}
if argumentsInJSON != "" && argumentsInJSON != "null" {
if err := json.Unmarshal([]byte(argumentsInJSON), &args); err != nil {
return "", fmt.Errorf("invalid tool arguments JSON: %w", err)
// Return soft error (nil error) so the eino graph continues and the LLM can self-correct,
// instead of a hard error that terminates the iteration loop.
return ToolErrorPrefix + fmt.Sprintf(
"Invalid tool arguments JSON: %s\n\nPlease ensure the arguments are a valid JSON object "+
"(double-quoted keys, matched braces, no trailing commas) and retry.\n\n"+
"(工具参数 JSON 解析失败:%s。请确保 arguments 是合法的 JSON 对象并重试。)",
err.Error(), err.Error()), nil
}
}
if args == nil {
+9 -1
View File
@@ -1256,7 +1256,7 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
// 保存用户消息:有附件时一并保存附件名与路径,刷新后显示、继续对话时大模型也能从历史中拿到路径
userContent := userMessageContentForStorage(req.Message, req.Attachments, savedPaths)
_, err = h.db.AddMessage(conversationID, "user", userContent, nil)
userMsgRow, err := h.db.AddMessage(conversationID, "user", userContent, nil)
if err != nil {
h.logger.Error("保存用户消息失败", zap.Error(err))
}
@@ -1275,6 +1275,14 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
assistantMessageID = assistantMsg.ID
}
// 尽早下发消息 ID,便于前端在流式结束前挂上「删除本轮」等(无需等整段结束再刷新)
if userMsgRow != nil {
sendEvent("message_saved", "", map[string]interface{}{
"conversationId": conversationID,
"userMessageId": userMsgRow.ID,
})
}
// 创建进度回调函数,复用统一逻辑
progressCallback := h.createProgressCallback(conversationID, assistantMessageID, sendEvent)
+32 -6
View File
@@ -93,22 +93,27 @@ func (h *ChatUploadsHandler) List(c *gin.Context) {
return
}
var files []ChatUploadFileItem
var folders []string
err = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
rel, err := filepath.Rel(root, path)
if err != nil {
return err
}
if rel == "." {
return nil
}
relSlash := filepath.ToSlash(rel)
if d.IsDir() {
folders = append(folders, relSlash)
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 {
@@ -142,10 +147,31 @@ func (h *ChatUploadsHandler) List(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if conversationFilter != "" {
filteredFolders := make([]string, 0, len(folders))
for _, rel := range folders {
parts := strings.Split(rel, "/")
if len(parts) >= 2 && parts[1] == conversationFilter {
filteredFolders = append(filteredFolders, rel)
continue
}
if len(parts) == 1 {
prefix := rel + "/"
for _, f := range files {
if strings.HasPrefix(f.RelativePath, prefix) {
filteredFolders = append(filteredFolders, rel)
break
}
}
}
}
folders = filteredFolders
}
sort.Strings(folders)
sort.Slice(files, func(i, j int) bool {
return files[i].ModifiedUnix > files[j].ModifiedUnix
})
c.JSON(http.StatusOK, gin.H{"files": files})
c.JSON(http.StatusOK, gin.H{"files": files, "folders": folders})
}
// Download GET /api/chat-uploads/download?path=...
+41
View File
@@ -190,3 +190,44 @@ func (h *ConversationHandler) DeleteConversation(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
}
// DeleteTurnRequest 删除一轮对话(POST /api/conversations/:id/delete-turn
type DeleteTurnRequest struct {
MessageID string `json:"messageId"`
}
// DeleteConversationTurn 删除锚点消息所在轮次(从该轮 user 到下一轮 user 之前),并清空 last_react_*。
func (h *ConversationHandler) DeleteConversationTurn(c *gin.Context) {
conversationID := c.Param("id")
if conversationID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "conversation id required"})
return
}
var req DeleteTurnRequest
if err := c.ShouldBindJSON(&req); err != nil || req.MessageID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "messageId required"})
return
}
if _, err := h.db.GetConversation(conversationID); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "对话不存在"})
return
}
deletedIDs, err := h.db.DeleteConversationTurn(conversationID, req.MessageID)
if err != nil {
h.logger.Warn("删除对话轮次失败",
zap.String("conversationId", conversationID),
zap.String("messageId", req.MessageID),
zap.Error(err),
)
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"deletedMessageIds": deletedIDs,
"message": "ok",
})
}
+7
View File
@@ -103,6 +103,13 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
conversationID := prep.ConversationID
assistantMessageID := prep.AssistantMessageID
if prep.UserMessageID != "" {
sendEvent("message_saved", "", map[string]interface{}{
"conversationId": conversationID,
"userMessageId": prep.UserMessageID,
})
}
progressCallback := h.createProgressCallback(conversationID, assistantMessageID, sendEvent)
baseCtx, cancelWithCause := context.WithCancelCause(context.Background())
+10 -3
View File
@@ -19,6 +19,7 @@ type multiAgentPrepared struct {
FinalMessage string
RoleTools []string
AssistantMessageID string
UserMessageID string
}
func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest) (*multiAgentPrepared, error) {
@@ -109,9 +110,14 @@ func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest) (*multiAgentPr
finalMessage = appendAttachmentsToMessage(finalMessage, req.Attachments, savedPaths)
userContent := userMessageContentForStorage(req.Message, req.Attachments, savedPaths)
if _, err = h.db.AddMessage(conversationID, "user", userContent, nil); err != nil {
h.logger.Error("保存用户消息失败", zap.Error(err))
return nil, fmt.Errorf("保存用户消息失败: %w", err)
userMsgRow, uerr := h.db.AddMessage(conversationID, "user", userContent, nil)
if uerr != nil {
h.logger.Error("保存用户消息失败", zap.Error(uerr))
return nil, fmt.Errorf("保存用户消息失败: %w", uerr)
}
userMessageID := ""
if userMsgRow != nil {
userMessageID = userMsgRow.ID
}
assistantMsg, aerr := h.db.AddMessage(conversationID, "assistant", "处理中...", nil)
@@ -129,5 +135,6 @@ func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest) (*multiAgentPr
FinalMessage: finalMessage,
RoleTools: roleTools,
AssistantMessageID: assistantMessageID,
UserMessageID: userMessageID,
}, nil
}
+1 -1
View File
@@ -444,7 +444,7 @@ func (s *Server) handleCallTool(msg *Message) *Message {
}
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
s.logger.Info("开始执行工具",
+44 -22
View File
@@ -302,34 +302,20 @@ func RunDeepAgent(
var lastRunMsgs []adk.Message
var lastAssistant string
// retryHints tracks the corrective hint to append for each retry attempt.
// Index i corresponds to the hint that will be appended on attempt i+1.
var retryHints []adk.Message
attemptLoop:
for attempt := 0; attempt < maxToolCallArgumentsJSONAttempts; attempt++ {
msgs := make([]adk.Message, 0, len(baseMsgs)+attempt)
for attempt := 0; attempt < maxToolCallRecoveryAttempts; attempt++ {
msgs := make([]adk.Message, 0, len(baseMsgs)+len(retryHints))
msgs = append(msgs, baseMsgs...)
for i := 0; i < attempt; i++ {
msgs = append(msgs, toolCallArgumentsJSONRetryHint())
}
msgs = append(msgs, retryHints...)
if attempt > 0 {
mcpIDsMu.Lock()
mcpIDs = mcpIDs[:0]
mcpIDsMu.Unlock()
if logger != nil {
logger.Warn("eino DeepAgent: 工具参数 JSON 被接口拒绝,追加提示后重试",
zap.Int("attempt", attempt),
zap.Int("maxAttempts", maxToolCallArgumentsJSONAttempts))
}
if progress != nil {
// 使用专用事件类型 eino_recovery,便于前端时间线展示(progress 仅改标题,不进时间线)
progress("eino_recovery", toolCallArgumentsJSONRecoveryTimelineMessage(attempt), map[string]interface{}{
"conversationId": conversationID,
"source": "eino",
"einoRetry": attempt,
"runIndex": attempt + 1, // 第几轮完整运行(1 为首次,重试后递增)
"maxRuns": maxToolCallArgumentsJSONAttempts,
"reason": "invalid_tool_arguments_json",
})
}
}
// 仅保留主代理最后一次 assistant 输出;每轮重试重置,避免拼接失败轮次的片段。
@@ -357,12 +343,48 @@ attemptLoop:
continue
}
if ev.Err != nil {
if isRecoverableToolCallArgumentsJSONError(ev.Err) && attempt+1 < maxToolCallArgumentsJSONAttempts {
canRetry := attempt+1 < maxToolCallRecoveryAttempts
// Recoverable: API-level JSON argument validation error.
if canRetry && isRecoverableToolCallArgumentsJSONError(ev.Err) {
if logger != nil {
logger.Warn("eino: recoverable tool-call JSON error from model/API", zap.Error(ev.Err), zap.Int("attempt", attempt))
}
retryHints = append(retryHints, toolCallArgumentsJSONRetryHint())
if progress != nil {
progress("eino_recovery", toolCallArgumentsJSONRecoveryTimelineMessage(attempt), map[string]interface{}{
"conversationId": conversationID,
"source": "eino",
"einoRetry": attempt,
"runIndex": attempt + 1,
"maxRuns": maxToolCallRecoveryAttempts,
"reason": "invalid_tool_arguments_json",
})
}
continue attemptLoop
}
// Recoverable: tool execution error (unknown sub-agent, tool not found, bad JSON in args, etc.).
if canRetry && isRecoverableToolExecutionError(ev.Err) {
if logger != nil {
logger.Warn("eino: recoverable tool execution error, will retry with corrective hint",
zap.Error(ev.Err), zap.Int("attempt", attempt))
}
retryHints = append(retryHints, toolExecutionRetryHint())
if progress != nil {
progress("eino_recovery", toolExecutionRecoveryTimelineMessage(attempt), map[string]interface{}{
"conversationId": conversationID,
"source": "eino",
"einoRetry": attempt,
"runIndex": attempt + 1,
"maxRuns": maxToolCallRecoveryAttempts,
"reason": "tool_execution_error",
})
}
continue attemptLoop
}
// Non-recoverable error.
if progress != nil {
progress("error", ev.Err.Error(), map[string]interface{}{
"conversationId": conversationID,
+4 -3
View File
@@ -7,9 +7,10 @@ import (
"github.com/cloudwego/eino/schema"
)
// maxToolCallArgumentsJSONAttempts 含首次运行:首次 + 自动重试次数。
// maxToolCallRecoveryAttempts 含首次运行:首次 + 自动重试次数。
// 例如为 3 表示最多共 3 次完整 DeepAgent 运行(2 次失败后各追加一条纠错提示)。
const maxToolCallArgumentsJSONAttempts = 3
// 该常量同时用于 JSON 参数错误和工具执行错误(如子代理名称不存在)的恢复重试。
const maxToolCallRecoveryAttempts = 3
// toolCallArgumentsJSONRetryHint 追加在用户消息后,提示模型输出合法 JSON 工具参数(部分云厂商会在流式阶段校验 arguments)。
func toolCallArgumentsJSONRetryHint() *schema.Message {
@@ -24,7 +25,7 @@ func toolCallArgumentsJSONRecoveryTimelineMessage(attempt int) string {
"接口拒绝了无效的工具参数 JSON。已向对话追加系统提示并要求模型重新生成合法的 function.arguments。"+
"当前为第 %d/%d 轮完整运行。\n\n"+
"The API rejected invalid JSON in tool arguments. A system hint was appended. This is full run %d of %d.",
attempt+1, maxToolCallArgumentsJSONAttempts, attempt+1, maxToolCallArgumentsJSONAttempts,
attempt+1, maxToolCallRecoveryAttempts, attempt+1, maxToolCallRecoveryAttempts,
)
}
@@ -0,0 +1,76 @@
package multiagent
import (
"fmt"
"strings"
"github.com/cloudwego/eino/schema"
)
// isRecoverableToolExecutionError detects tool-level execution errors that can be
// recovered by retrying with a corrective hint. These errors originate from eino
// framework internals (e.g. task_tool.go, tool_node.go) when the LLM produces
// invalid tool calls such as non-existent sub-agent types, malformed JSON arguments,
// or unregistered tool names.
func isRecoverableToolExecutionError(err error) bool {
if err == nil {
return false
}
s := strings.ToLower(err.Error())
// Sub-agent type not found (from deep/task_tool.go)
if strings.Contains(s, "subagent type") && strings.Contains(s, "not found") {
return true
}
// Tool not found in toolsNode indexes (from compose/tool_node.go, when UnknownToolsHandler is nil)
if strings.Contains(s, "tool") && strings.Contains(s, "not found") {
return true
}
// Invalid tool arguments JSON (from einomcp/mcp_tools.go or eino internals)
if strings.Contains(s, "invalid tool arguments json") {
return true
}
// Failed to unmarshal task tool input json (from deep/task_tool.go)
if strings.Contains(s, "failed to unmarshal") && strings.Contains(s, "json") {
return true
}
// Generic tool call stream/invoke failure wrapping the above
if (strings.Contains(s, "failed to stream tool call") || strings.Contains(s, "failed to invoke tool")) &&
(strings.Contains(s, "not found") || strings.Contains(s, "json") || strings.Contains(s, "unmarshal")) {
return true
}
return false
}
// toolExecutionRetryHint returns a user message appended to the conversation to prompt
// the LLM to correct its tool call after a tool execution error.
func toolExecutionRetryHint() *schema.Message {
return schema.UserMessage(`[System] Your previous tool call failed because:
- The tool or sub-agent name you used does not exist, OR
- The tool call arguments were not valid JSON.
Please carefully review the available tools and sub-agents listed in your context, use only exact registered names (case-sensitive), and ensure all arguments are well-formed JSON objects. Then retry your action.
[系统提示] 上一次工具调用失败可能原因
- 你使用的工具名或子代理名称不存在
- 工具调用参数不是合法 JSON
请仔细检查上下文中列出的可用工具和子代理名称须完全匹配区分大小写确保所有参数均为合法的 JSON 对象然后重新执行`)
}
// toolExecutionRecoveryTimelineMessage returns a message for the eino_recovery event
// displayed in the UI timeline when a tool execution error triggers a retry.
func toolExecutionRecoveryTimelineMessage(attempt int) string {
return fmt.Sprintf(
"工具调用执行失败(工具/子代理名称不存在或参数 JSON 无效)。已向对话追加纠错提示并要求模型重新生成。"+
"当前为第 %d/%d 轮完整运行。\n\n"+
"Tool call execution failed (unknown tool/sub-agent name or invalid JSON arguments). "+
"A corrective hint was appended. This is full run %d of %d.",
attempt+1, maxToolCallRecoveryAttempts, attempt+1, maxToolCallRecoveryAttempts,
)
}
+155
View File
@@ -6,7 +6,9 @@ import (
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"runtime"
"strconv"
"strings"
"sync"
@@ -16,6 +18,7 @@ import (
"cyberstrike-ai/internal/mcp"
"cyberstrike-ai/internal/storage"
"github.com/creack/pty"
"go.uber.org/zap"
)
@@ -149,6 +152,7 @@ func (e *Executor) ExecuteTool(ctx context.Context, toolName string, args map[st
// 执行命令
cmd := exec.CommandContext(ctx, toolConfig.Command, cmdArgs...)
applyDefaultTerminalEnv(cmd)
e.logger.Info("执行安全工具",
zap.String("tool", toolName),
@@ -160,10 +164,26 @@ func (e *Executor) ExecuteTool(ctx context.Context, toolName string, args map[st
// 如果上层提供了 stdout/stderr 增量回调,则边执行边读取并回调。
if cb, ok := ctx.Value(ToolOutputCallbackCtxKey).(ToolOutputCallback); ok && cb != nil {
output, err = streamCommandOutput(cmd, cb)
if err != nil && shouldRetryWithPTY(output) {
e.logger.Info("检测到工具需要 TTY,使用 PTY 重试",
zap.String("tool", toolName),
)
cmd2 := exec.CommandContext(ctx, toolConfig.Command, cmdArgs...)
applyDefaultTerminalEnv(cmd2)
output, err = runCommandWithPTY(ctx, cmd2, cb)
}
} else {
outputBytes, err2 := cmd.CombinedOutput()
output = string(outputBytes)
err = err2
if err != nil && shouldRetryWithPTY(output) {
e.logger.Info("检测到工具需要 TTY,使用 PTY 重试",
zap.String("tool", toolName),
)
cmd2 := exec.CommandContext(ctx, toolConfig.Command, cmdArgs...)
applyDefaultTerminalEnv(cmd2)
output, err = runCommandWithPTY(ctx, cmd2, nil)
}
}
if err != nil {
// 检查退出码是否在允许列表中
@@ -956,10 +976,28 @@ func (e *Executor) executeSystemCommand(ctx context.Context, args map[string]int
// 若上层提供工具输出增量回调,则边执行边流式读取。
if cb, ok := ctx.Value(ToolOutputCallbackCtxKey).(ToolOutputCallback); ok && cb != nil {
output, err = streamCommandOutput(cmd, cb)
if err != nil && shouldRetryWithPTY(output) {
e.logger.Info("检测到系统命令需要 TTY,使用 PTY 重试")
cmd2 := exec.CommandContext(ctx, shell, "-c", command)
if workDir != "" {
cmd2.Dir = workDir
}
applyDefaultTerminalEnv(cmd2)
output, err = runCommandWithPTY(ctx, cmd2, cb)
}
} else {
outputBytes, err2 := cmd.CombinedOutput()
output = string(outputBytes)
err = err2
if err != nil && shouldRetryWithPTY(output) {
e.logger.Info("检测到系统命令需要 TTY,使用 PTY 重试")
cmd2 := exec.CommandContext(ctx, shell, "-c", command)
if workDir != "" {
cmd2.Dir = workDir
}
applyDefaultTerminalEnv(cmd2)
output, err = runCommandWithPTY(ctx, cmd2, nil)
}
}
if err != nil {
e.logger.Error("系统命令执行失败",
@@ -1066,6 +1104,123 @@ func streamCommandOutput(cmd *exec.Cmd, cb ToolOutputCallback) (string, error) {
return outBuilder.String(), waitErr
}
// applyDefaultTerminalEnv 为外部工具补齐常见的终端环境变量。
// 注意:这不会创建 TTY,只是减少某些工具在非交互环境下的“奇怪排版/检测失败”。
func applyDefaultTerminalEnv(cmd *exec.Cmd) {
if cmd == nil {
return
}
// 仅在未显式设置 Env 时,继承当前进程环境
if cmd.Env == nil {
cmd.Env = os.Environ()
}
// 如果用户已设置 TERM/COLUMNS/LINES,则不覆盖
has := func(k string) bool {
prefix := k + "="
for _, e := range cmd.Env {
if strings.HasPrefix(e, prefix) {
return true
}
}
return false
}
if !has("TERM") {
cmd.Env = append(cmd.Env, "TERM=xterm-256color")
}
if !has("COLUMNS") {
cmd.Env = append(cmd.Env, "COLUMNS=256")
}
if !has("LINES") {
cmd.Env = append(cmd.Env, "LINES=40")
}
}
func shouldRetryWithPTY(output string) bool {
o := strings.ToLower(output)
// autorecon / python termios 常见报错
if strings.Contains(o, "inappropriate ioctl for device") {
return true
}
if strings.Contains(o, "termios.error") {
return true
}
// 兜底:stdin 不是 tty
if strings.Contains(o, "not a tty") {
return true
}
return false
}
// runCommandWithPTY 为子进程分配 PTY,适配需要交互式终端的工具(如 autorecon)。
// 若 cb != nil,将持续回调增量输出(用于 SSE)。
func runCommandWithPTY(ctx context.Context, cmd *exec.Cmd, cb ToolOutputCallback) (string, error) {
if runtime.GOOS == "windows" {
// PTY 方案为类 UnixWindows 走原逻辑
if cb != nil {
return streamCommandOutput(cmd, cb)
}
out, err := cmd.CombinedOutput()
return string(out), err
}
ptmx, err := pty.Start(cmd)
if err != nil {
return "", err
}
defer func() { _ = ptmx.Close() }()
// ctx 取消时尽快终止子进程
done := make(chan struct{})
go func() {
select {
case <-ctx.Done():
_ = ptmx.Close() // 触发读退出
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
case <-done:
}
}()
defer close(done)
var outBuilder strings.Builder
var deltaBuilder strings.Builder
lastFlush := time.Now()
flush := func() {
if cb == nil || deltaBuilder.Len() == 0 {
deltaBuilder.Reset()
lastFlush = time.Now()
return
}
cb(deltaBuilder.String())
deltaBuilder.Reset()
lastFlush = time.Now()
}
buf := make([]byte, 4096)
for {
n, readErr := ptmx.Read(buf)
if n > 0 {
chunk := string(buf[:n])
// 统一换行为 \n,避免前端错位
chunk = strings.ReplaceAll(chunk, "\r\n", "\n")
chunk = strings.ReplaceAll(chunk, "\r", "\n")
outBuilder.WriteString(chunk)
deltaBuilder.WriteString(chunk)
if deltaBuilder.Len() >= 2048 || time.Since(lastFlush) >= 200*time.Millisecond {
flush()
}
}
if readErr != nil {
break
}
}
flush()
waitErr := cmd.Wait()
return outBuilder.String(), waitErr
}
// executeInternalTool 执行内部工具(不执行外部命令)
func (e *Executor) executeInternalTool(ctx context.Context, toolName string, command string, args map[string]interface{}) (*mcp.ToolResult, error) {
// 提取内部工具类型(去掉 "internal:" 前缀)
+75
View File
@@ -1573,6 +1573,81 @@ header {
letter-spacing: 0.01em;
}
/* 时间戳 + 删除本轮(与气泡分离,和「展开详情」同一视觉层级) */
.message-meta-footer {
display: flex;
flex-direction: row;
align-items: center; /* 与时间戳、删除钮统一垂直居中 */
gap: 8px;
margin-top: 6px;
flex-wrap: wrap;
width: 100%;
min-height: 28px;
}
.message.user .message-meta-footer {
justify-content: flex-end;
}
.message.assistant .message-meta-footer {
justify-content: flex-start;
}
.message-delete-turn-btn {
position: static;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: var(--text-secondary, #888);
cursor: pointer;
opacity: 0.5;
flex-shrink: 0;
transition: opacity 0.2s ease, color 0.2s ease, background 0.2s ease, border-color 0.2s ease;
}
.message:hover .message-meta-footer .message-delete-turn-btn {
opacity: 0.85;
}
.message-delete-turn-btn:hover {
color: #c62828;
background: rgba(198, 40, 40, 0.07);
border-color: rgba(198, 40, 40, 0.15);
opacity: 1;
}
.message-delete-turn-btn:focus-visible {
opacity: 1;
outline: 2px solid var(--accent-color, #0066ff);
outline-offset: 1px;
}
/* 与删除钮同一行时:去掉时间戳默认 margin-top,避免文字偏低、图标显「高」 */
.message-meta-footer .message-time {
margin-top: 0;
display: inline-flex;
align-items: center;
min-height: 28px;
line-height: 1.3;
}
.message-delete-turn-btn svg {
display: block;
flex-shrink: 0;
}
@media (hover: none) {
.message-delete-turn-btn {
opacity: 0.65;
}
}
/* 用户消息中的表格样式 */
.message.user .message-bubble .table-wrapper {
scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
+4
View File
@@ -138,6 +138,9 @@
"expandDetail": "Expand details",
"noProcessDetail": "No process details (execution may be too fast or no detailed events)",
"copyMessageTitle": "Copy message",
"deleteTurnTitle": "Delete this turn",
"deleteTurnConfirm": "Delete this entire turn (user message and assistant reply)? This cannot be undone. The next reply will use only the remaining messages; saved context snapshots will be cleared.",
"deleteTurnFailed": "Failed to delete turn",
"emptyGroupConversations": "This group has no conversations yet.",
"noMatchingConversationsInGroup": "No matching conversations found.",
"noHistoryConversations": "No conversation history yet",
@@ -1107,6 +1110,7 @@
"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.",
"folderRemovedStale": "That folder is not on the server anymore; list refreshed.",
"deleteFolderTitle": "Delete folder",
"uploadToFolderTitle": "Upload file into this folder",
"newFolderButton": "New folder",
+4
View File
@@ -138,6 +138,9 @@
"expandDetail": "展开详情",
"noProcessDetail": "暂无过程详情(可能执行过快或未触发详细事件)",
"copyMessageTitle": "复制消息内容",
"deleteTurnTitle": "删除本轮对话",
"deleteTurnConfirm": "确定删除本轮对话?将同时删除该轮用户消息与助手回复,且无法恢复;下次模型回复将仅基于剩余消息(已保存的上下文快照会清空并按剩余内容重建)。",
"deleteTurnFailed": "删除本轮失败",
"emptyGroupConversations": "该分组暂无对话",
"noMatchingConversationsInGroup": "未找到匹配的对话",
"noHistoryConversations": "暂无历史对话",
@@ -1107,6 +1110,7 @@
"folderPathCopied": "目录路径已复制,可粘贴到对话中",
"folderEmpty": "此文件夹为空",
"confirmDeleteFolder": "确定删除该文件夹及其中的全部文件?此操作不可恢复。",
"folderRemovedStale": "服务器上已无该目录,列表已刷新。",
"deleteFolderTitle": "删除文件夹",
"uploadToFolderTitle": "上传文件到此文件夹",
"newFolderButton": "新建文件夹",
+58 -98
View File
@@ -1,6 +1,8 @@
// 对话附件(chat_uploads)文件管理
let chatFilesCache = [];
/** 后端 GET /api/chat-uploads 返回的目录相对路径(含空文件夹),与 files 合并成树 */
let chatFilesFoldersCache = [];
let chatFilesDisplayed = [];
let chatFilesEditRelativePath = '';
let chatFilesRenameRelativePath = '';
@@ -15,98 +17,6 @@ let chatFilesPendingUploadDir = '';
/** 文件管理页面向服务器上传进行中,避免重复选择并禁用顶栏按钮 */
let chatFilesXHRUploadBusy = false;
/** 仅前端记录的「空目录」键 parentPath'' 表示 chat_uploads 根)-> 子目录名列表,与树合并以便 mkdir 后可见 */
const CHAT_FILES_SYNTHETIC_DIRS_KEY = 'csai_chat_files_synthetic_dirs';
let chatFilesSyntheticEmptyDirs = {};
function chatFilesLoadSyntheticDirsFromStorage() {
try {
const raw = localStorage.getItem(CHAT_FILES_SYNTHETIC_DIRS_KEY);
if (!raw) return;
const o = JSON.parse(raw);
if (o && typeof o === 'object') {
chatFilesSyntheticEmptyDirs = o;
}
} catch (e) {
chatFilesSyntheticEmptyDirs = {};
}
}
function chatFilesRegisterSyntheticEmptyDir(parentSegments, name) {
const p = parentSegments.join('/');
if (!chatFilesSyntheticEmptyDirs[p]) {
chatFilesSyntheticEmptyDirs[p] = [];
}
const arr = chatFilesSyntheticEmptyDirs[p];
if (arr.indexOf(name) === -1) {
arr.push(name);
}
try {
localStorage.setItem(CHAT_FILES_SYNTHETIC_DIRS_KEY, JSON.stringify(chatFilesSyntheticEmptyDirs));
} catch (e) {
/* ignore */
}
}
function chatFilesRemoveSyntheticDirSubtree(relPathUnderRoot) {
const rel = String(relPathUnderRoot || '').replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, '');
if (!rel) return;
const parts = rel.split('/').filter(function (x) {
return x.length > 0;
});
if (parts.length === 0) return;
const leaf = parts[parts.length - 1];
const parentKey = parts.slice(0, -1).join('/');
const arr = chatFilesSyntheticEmptyDirs[parentKey];
if (arr) {
const ix = arr.indexOf(leaf);
if (ix >= 0) arr.splice(ix, 1);
if (arr.length === 0) delete chatFilesSyntheticEmptyDirs[parentKey];
}
const prefix = rel + '/';
let k;
for (k in chatFilesSyntheticEmptyDirs) {
if (!Object.prototype.hasOwnProperty.call(chatFilesSyntheticEmptyDirs, k)) continue;
if (k === rel || k.indexOf(prefix) === 0) {
delete chatFilesSyntheticEmptyDirs[k];
}
}
try {
localStorage.setItem(CHAT_FILES_SYNTHETIC_DIRS_KEY, JSON.stringify(chatFilesSyntheticEmptyDirs));
} catch (e) {
/* ignore */
}
}
function chatFilesMergeSyntheticDirsIntoTree(root) {
function ensurePath(node, segments) {
let n = node;
let i;
for (i = 0; i < segments.length; i++) {
const s = segments[i];
if (!n.dirs[s]) n.dirs[s] = chatFilesTreeMakeNode();
n = n.dirs[s];
}
return n;
}
let k;
for (k in chatFilesSyntheticEmptyDirs) {
if (!Object.prototype.hasOwnProperty.call(chatFilesSyntheticEmptyDirs, k)) continue;
const names = chatFilesSyntheticEmptyDirs[k];
if (!Array.isArray(names)) continue;
const segs = k ? k.split('/').filter(function (x) {
return x.length > 0;
}) : [];
const node = ensurePath(root, segs);
let ni;
for (ni = 0; ni < names.length; ni++) {
const nm = names[ni];
if (!nm || typeof nm !== 'string') continue;
if (!node.dirs[nm]) node.dirs[nm] = chatFilesTreeMakeNode();
}
}
}
function chatFilesLoadBrowsePathFromStorage() {
try {
const raw = localStorage.getItem(CHAT_FILES_BROWSE_PATH_KEY);
@@ -157,7 +67,11 @@ function chatFilesNormalizeBrowsePathForTree(root) {
function initChatFilesPage() {
chatFilesLoadBrowsePathFromStorage();
chatFilesLoadSyntheticDirsFromStorage();
try {
localStorage.removeItem('csai_chat_files_synthetic_dirs');
} catch (e) {
/* ignore */
}
ensureChatFilesDocClickClose();
const sel = document.getElementById('chat-files-group-by');
if (sel) {
@@ -280,6 +194,7 @@ async function loadChatFilesPage() {
}
const data = await res.json();
chatFilesCache = Array.isArray(data.files) ? data.files : [];
chatFilesFoldersCache = Array.isArray(data.folders) ? data.folders : [];
renderChatFilesTable();
} catch (e) {
console.error(e);
@@ -303,7 +218,7 @@ function chatFilesNameFilter(files) {
/** 仅前端按文件名筛选,不重新请求 */
function chatFilesFilterNameOnInput() {
if (!chatFilesCache.length && chatFilesGetGroupByMode() !== 'folder') return;
if (!chatFilesCache.length && !chatFilesFoldersCache.length && chatFilesGetGroupByMode() !== 'folder') return;
renderChatFilesTable();
}
@@ -463,9 +378,34 @@ function chatFilesBuildTree(files) {
return root;
}
/** 将后端返回的目录相对路径(如 a/b/c)并入树,便于展示空文件夹 */
function chatFilesTreeInsertFolderPath(root, relSlash) {
const rp = String(relSlash || '').replace(/\\/g, '/').replace(/^\/+/, '');
if (!rp) return;
const parts = rp.split('/').filter(function (p) {
return p.length > 0;
});
if (!parts.length) return;
let node = root;
let i;
for (i = 0; i < parts.length; i++) {
const seg = parts[i];
if (!node.dirs[seg]) node.dirs[seg] = chatFilesTreeMakeNode();
node = node.dirs[seg];
}
}
function chatFilesMergeFoldersIntoTree(root, folderPaths) {
if (!Array.isArray(folderPaths)) return;
let i;
for (i = 0; i < folderPaths.length; i++) {
chatFilesTreeInsertFolderPath(root, folderPaths[i]);
}
}
function chatFilesTreeRootMerged() {
const root = chatFilesBuildTree(chatFilesDisplayed);
chatFilesMergeSyntheticDirsIntoTree(root);
chatFilesMergeFoldersIntoTree(root, chatFilesFoldersCache);
return root;
}
@@ -907,9 +847,30 @@ async function deleteChatFolderFromBrowse(folderName) {
body: JSON.stringify({ path: rel })
});
if (!res.ok) {
throw new Error(await res.text());
const raw = await res.text();
if (res.status === 404) {
let errMsg = raw;
try {
const j = JSON.parse(raw);
if (j && j.error) errMsg = j.error;
} catch (eParse) {
/* keep raw */
}
if (/not\s*found/i.test(String(errMsg))) {
loadChatFilesPage();
const cleared = (typeof window.t === 'function')
? window.t('chatFilesPage.folderRemovedStale')
: '服务器上不存在该目录,列表已刷新。';
if (typeof chatFilesShowToast === 'function') {
chatFilesShowToast(cleared);
} else {
alert(cleared);
}
return;
}
}
throw new Error(raw || String(res.status));
}
chatFilesRemoveSyntheticDirSubtree(rel);
loadChatFilesPage();
} catch (e) {
alert((e && e.message) ? e.message : String(e));
@@ -1188,7 +1149,6 @@ async function submitChatFilesMkdir() {
}
throw new Error(errText || String(res.status));
}
chatFilesRegisterSyntheticEmptyDir(chatFilesBrowsePath.slice(), name);
closeChatFilesMkdirModal();
loadChatFilesPage();
const okMsg = (typeof window.t === 'function')
+62
View File
@@ -2452,6 +2452,7 @@ async function loadConversation(conversationId) {
const messageEl = document.getElementById(messageId);
if (messageEl && msg && msg.id) {
messageEl.dataset.backendMessageId = String(msg.id);
attachDeleteTurnButton(messageEl);
}
// 对于助手消息,总是渲染过程详情(即使没有processDetails也要显示展开详情按钮)
if (msg.role === 'assistant') {
@@ -2491,6 +2492,67 @@ async function loadConversation(conversationId) {
}
}
/** 「删除本轮」:与时间戳同一行(message-meta-footer),风格与复制按钮区区分 */
function attachDeleteTurnButton(messageEl) {
if (!messageEl || !messageEl.dataset.backendMessageId) return;
if (messageEl.querySelector('.message-delete-turn-btn')) return;
const content = messageEl.querySelector('.message-content');
if (!content) return;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'message-delete-turn-btn';
const title = typeof window.t === 'function' ? window.t('chat.deleteTurnTitle') : '删除本轮对话';
btn.title = title;
btn.setAttribute('aria-label', title);
btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6h14zM10 11v6M14 11v6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
btn.onclick = function (e) {
e.stopPropagation();
e.preventDefault();
deleteConversationTurnFromUI(messageEl.dataset.backendMessageId);
};
const timeDiv = content.querySelector('.message-time');
let footer = content.querySelector('.message-meta-footer');
if (!footer && timeDiv && timeDiv.parentNode === content) {
footer = document.createElement('div');
footer.className = 'message-meta-footer';
timeDiv.parentNode.insertBefore(footer, timeDiv);
footer.appendChild(timeDiv);
}
if (footer) {
footer.appendChild(btn);
} else {
content.appendChild(btn);
}
}
/** 删除锚点所在整轮(后端:该轮 user 至下一轮 user 之前),并清空 ReAct 快照 */
async function deleteConversationTurnFromUI(anchorBackendMessageId) {
if (!currentConversationId || !anchorBackendMessageId) return;
const confirmMsg = typeof window.t === 'function' ? window.t('chat.deleteTurnConfirm') : '确定删除本轮对话?';
if (!confirm(confirmMsg)) return;
try {
const response = await apiFetch(`/api/conversations/${currentConversationId}/delete-turn`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messageId: anchorBackendMessageId })
});
let data = {};
try {
data = await response.json();
} catch (e) { /* ignore */ }
if (!response.ok) {
throw new Error(data.error || data.message || 'delete failed');
}
await loadConversation(currentConversationId);
if (typeof loadConversations === 'function') loadConversations();
if (typeof loadConversationsWithGroups === 'function') loadConversationsWithGroups();
} catch (error) {
console.error('delete turn failed:', error);
const failed = typeof window.t === 'function' ? window.t('chat.deleteTurnFailed') : '删除本轮失败';
alert(failed + ': ' + (error && error.message ? error.message : error));
}
}
// 删除对话
async function deleteConversation(conversationId, skipConfirm = false) {
// 确认删除(如果调用者没有跳过确认)
+46
View File
@@ -700,10 +700,45 @@ function convertProgressToDetails(progressId, assistantMessageId) {
scrollChatMessagesToBottomIfPinned(insertWasPinned);
}
/** 将后端消息 UUID 绑定到助手气泡,供删除本轮 / 过程详情懒加载(domId 为前端 msg-* */
function applyBackendMessageIdToAssistantDom(domAssistantId, backendMessageId) {
if (!domAssistantId || !backendMessageId) return;
const el = document.getElementById(domAssistantId);
if (!el) return;
el.dataset.backendMessageId = String(backendMessageId);
if (typeof attachDeleteTurnButton === 'function') {
attachDeleteTurnButton(el);
}
}
/** 将后端用户消息 ID 绑定到最后一条尚未绑定 backendMessageId 的用户气泡 */
function applyBackendMessageIdToLastUser(backendMessageId) {
if (!backendMessageId) return;
const users = document.querySelectorAll('#chat-messages .message.user');
if (!users.length) return;
const lastUser = users[users.length - 1];
if (lastUser.dataset.backendMessageId) return;
lastUser.dataset.backendMessageId = String(backendMessageId);
if (typeof attachDeleteTurnButton === 'function') {
attachDeleteTurnButton(lastUser);
}
}
// 处理流式事件
function handleStreamEvent(event, progressElement, progressId,
getAssistantId, setAssistantId, getMcpIds, setMcpIds) {
const streamScrollWasPinned = isChatMessagesPinnedToBottom();
// 不依赖进度时间线;在首条 SSE 即可绑定用户消息 ID
if (event.type === 'message_saved') {
const d = event.data || {};
if (d.userMessageId) {
applyBackendMessageIdToLastUser(d.userMessageId);
}
scrollChatMessagesToBottomIfPinned(streamScrollWasPinned);
return;
}
const timeline = document.getElementById(progressId + '-timeline');
if (!timeline) return;
@@ -1173,6 +1208,9 @@ function handleStreamEvent(event, progressElement, progressId,
{
const preferredMessageId = event.data && event.data.messageId ? event.data.messageId : null;
const { assistantId, assistantElement } = upsertTerminalAssistantMessage(event.message, preferredMessageId);
if (assistantId && preferredMessageId) {
applyBackendMessageIdToAssistantDom(assistantId, preferredMessageId);
}
if (assistantElement) {
const detailsId = 'process-details-' + assistantId;
if (!document.getElementById(detailsId)) {
@@ -1306,6 +1344,11 @@ function handleStreamEvent(event, progressElement, progressId,
integrateProgressToMCPSection(progressId, assistantIdFinal, mcpIds);
responseStreamStateByProgressId.delete(progressId);
const respMid = responseData.messageId;
if (respMid) {
applyBackendMessageIdToAssistantDom(assistantIdFinal, respMid);
}
setTimeout(() => {
collapseAllProgressDetails(assistantIdFinal, progressId);
}, 3000);
@@ -1344,6 +1387,9 @@ function handleStreamEvent(event, progressElement, progressId,
{
const preferredMessageId = event.data && event.data.messageId ? event.data.messageId : null;
const { assistantId, assistantElement } = upsertTerminalAssistantMessage(event.message, preferredMessageId);
if (assistantId && preferredMessageId) {
applyBackendMessageIdToAssistantDom(assistantId, preferredMessageId);
}
if (assistantElement) {
const detailsId = 'process-details-' + assistantId;
if (!document.getElementById(detailsId)) {