mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-05-16 21:23:29 +02:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a273d6d7ba | |||
| 87d9e50781 | |||
| 54b9e2e2fa | |||
| 946d347dc9 | |||
| ed8c0b15dd | |||
| f658cc6e93 | |||
| 7bf0697526 | |||
| 7e8cc3e2b8 | |||
| 0183d9f15f | |||
| 7d7207c12f | |||
| 9eb47d96f5 | |||
| cf1c9c199c | |||
| ce5f20c11e | |||
| d87bc09a2e | |||
| 6cd89414f9 | |||
| e538a744c3 | |||
| dd4d534e24 | |||
| f1a31a459c | |||
| 4fd083ff37 | |||
| acef729800 | |||
| e7609c5fc4 | |||
| 2b6d0486c8 | |||
| d5eb4ce119 | |||
| 92a8339267 |
@@ -16,6 +16,17 @@
|
|||||||
|
|
||||||
</details>
|
</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.
|
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.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,17 @@
|
|||||||
|
|
||||||
</details>
|
</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 智能体,支持从对话指令到漏洞发现、攻击链分析、知识检索与结果可视化的全流程自动化,为安全团队提供可审计、可追溯、可协作的专业测试环境。
|
CyberStrikeAI 是一款 **AI 原生安全测试平台**,基于 Go 构建,集成了 100+ 安全工具、智能编排引擎、角色化测试与预设安全测试角色、Skills 技能系统与专业测试技能,以及完整的测试生命周期管理能力。通过原生 MCP 协议与 AI 智能体,支持从对话指令到漏洞发现、攻击链分析、知识检索与结果可视化的全流程自动化,为安全团队提供可审计、可追溯、可协作的专业测试环境。
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||||
version: "v1.4.7"
|
version: "v1.4.12"
|
||||||
# 服务器配置
|
# 服务器配置
|
||||||
server:
|
server:
|
||||||
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
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 |
@@ -664,6 +664,7 @@ func setupRoutes(
|
|||||||
protected.GET("/messages/:id/process-details", conversationHandler.GetMessageProcessDetails)
|
protected.GET("/messages/:id/process-details", conversationHandler.GetMessageProcessDetails)
|
||||||
protected.PUT("/conversations/:id", conversationHandler.UpdateConversation)
|
protected.PUT("/conversations/:id", conversationHandler.UpdateConversation)
|
||||||
protected.DELETE("/conversations/:id", conversationHandler.DeleteConversation)
|
protected.DELETE("/conversations/:id", conversationHandler.DeleteConversation)
|
||||||
|
protected.POST("/conversations/:id/delete-turn", conversationHandler.DeleteConversationTurn)
|
||||||
protected.PUT("/conversations/:id/pinned", groupHandler.UpdateConversationPinned)
|
protected.PUT("/conversations/:id/pinned", groupHandler.UpdateConversationPinned)
|
||||||
|
|
||||||
// 对话分组
|
// 对话分组
|
||||||
@@ -674,6 +675,7 @@ func setupRoutes(
|
|||||||
protected.DELETE("/groups/:id", groupHandler.DeleteGroup)
|
protected.DELETE("/groups/:id", groupHandler.DeleteGroup)
|
||||||
protected.PUT("/groups/:id/pinned", groupHandler.UpdateGroupPinned)
|
protected.PUT("/groups/:id/pinned", groupHandler.UpdateGroupPinned)
|
||||||
protected.GET("/groups/:id/conversations", groupHandler.GetGroupConversations)
|
protected.GET("/groups/:id/conversations", groupHandler.GetGroupConversations)
|
||||||
|
protected.GET("/groups/mappings", groupHandler.GetAllMappings)
|
||||||
protected.POST("/groups/conversations", groupHandler.AddConversationToGroup)
|
protected.POST("/groups/conversations", groupHandler.AddConversationToGroup)
|
||||||
protected.DELETE("/groups/:id/conversations/:conversationId", groupHandler.RemoveConversationFromGroup)
|
protected.DELETE("/groups/:id/conversations/:conversationId", groupHandler.RemoveConversationFromGroup)
|
||||||
protected.PUT("/groups/:id/conversations/:conversationId/pinned", groupHandler.UpdateConversationPinnedInGroup)
|
protected.PUT("/groups/:id/conversations/:conversationId/pinned", groupHandler.UpdateConversationPinnedInGroup)
|
||||||
@@ -681,6 +683,7 @@ func setupRoutes(
|
|||||||
// 监控
|
// 监控
|
||||||
protected.GET("/monitor", monitorHandler.Monitor)
|
protected.GET("/monitor", monitorHandler.Monitor)
|
||||||
protected.GET("/monitor/execution/:id", monitorHandler.GetExecution)
|
protected.GET("/monitor/execution/:id", monitorHandler.GetExecution)
|
||||||
|
protected.POST("/monitor/executions/names", monitorHandler.BatchGetToolNames)
|
||||||
protected.DELETE("/monitor/execution/:id", monitorHandler.DeleteExecution)
|
protected.DELETE("/monitor/execution/:id", monitorHandler.DeleteExecution)
|
||||||
protected.DELETE("/monitor/executions", monitorHandler.DeleteExecutions)
|
protected.DELETE("/monitor/executions", monitorHandler.DeleteExecutions)
|
||||||
protected.GET("/monitor/stats", monitorHandler.GetStats)
|
protected.GET("/monitor/stats", monitorHandler.GetStats)
|
||||||
@@ -690,6 +693,7 @@ func setupRoutes(
|
|||||||
protected.GET("/config/tools", configHandler.GetTools)
|
protected.GET("/config/tools", configHandler.GetTools)
|
||||||
protected.PUT("/config", configHandler.UpdateConfig)
|
protected.PUT("/config", configHandler.UpdateConfig)
|
||||||
protected.POST("/config/apply", configHandler.ApplyConfig)
|
protected.POST("/config/apply", configHandler.ApplyConfig)
|
||||||
|
protected.POST("/config/test-openai", configHandler.TestOpenAI)
|
||||||
|
|
||||||
// 系统设置 - 终端(执行命令,提高运维效率)
|
// 系统设置 - 终端(执行命令,提高运维效率)
|
||||||
protected.POST("/terminal/run", terminalHandler.RunCommand)
|
protected.POST("/terminal/run", terminalHandler.RunCommand)
|
||||||
|
|||||||
@@ -97,7 +97,8 @@ func (b *Builder) BuildChainFromConversation(ctx context.Context, conversationID
|
|||||||
return &Chain{Nodes: []Node{}, Edges: []Edge{}}, nil
|
return &Chain{Nodes: []Node{}, Edges: []Edge{}}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否有实际的工具执行(通过检查assistant消息的mcp_execution_ids)
|
// 检查是否有实际的工具执行:assistant 的 mcp_execution_ids,或过程详情中的 tool_call/tool_result
|
||||||
|
//(多代理下若 MCP 未返回 execution_id,IDs 可能为空,但工具已通过 Eino 执行并写入 process_details)
|
||||||
hasToolExecutions := false
|
hasToolExecutions := false
|
||||||
for i := len(messages) - 1; i >= 0; i-- {
|
for i := len(messages) - 1; i >= 0; i-- {
|
||||||
if strings.EqualFold(messages[i].Role, "assistant") {
|
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)
|
// 检查任务是否被取消(通过检查最后一条assistant消息内容或process_details)
|
||||||
taskCancelled := false
|
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,一次性传递给大模型
|
// 3. 构建简化的prompt,一次性传递给大模型
|
||||||
prompt := b.buildSimplePrompt(reactInputFinal, modelOutput)
|
prompt := b.buildSimplePrompt(reactInputFinal, modelOutput)
|
||||||
// fmt.Println(prompt)
|
// fmt.Println(prompt)
|
||||||
@@ -240,6 +279,93 @@ func (b *Builder) BuildChainFromConversation(ctx context.Context, conversationID
|
|||||||
return chainData, nil
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 data(JSON 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的输入(历史消息+当前用户输入)
|
// buildReActInput 构建最后一轮ReAct的输入(历史消息+当前用户输入)
|
||||||
func (b *Builder) buildReActInput(messages []database.Message) string {
|
func (b *Builder) buildReActInput(messages []database.Message) string {
|
||||||
var builder strings.Builder
|
var builder strings.Builder
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -309,14 +310,13 @@ func (db *DB) ListConversations(limit, offset int, search string) ([]*Conversati
|
|||||||
var err error
|
var err error
|
||||||
|
|
||||||
if search != "" {
|
if search != "" {
|
||||||
// 使用LIKE进行模糊搜索,搜索标题和消息内容
|
// 使用 EXISTS 子查询代替 LEFT JOIN + DISTINCT,避免大表笛卡尔积
|
||||||
searchPattern := "%" + search + "%"
|
searchPattern := "%" + search + "%"
|
||||||
// 使用DISTINCT避免重复,因为一个对话可能有多条消息匹配
|
|
||||||
rows, err = db.Query(
|
rows, err = db.Query(
|
||||||
`SELECT DISTINCT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at
|
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at
|
||||||
FROM conversations c
|
FROM conversations c
|
||||||
LEFT JOIN messages m ON c.id = m.conversation_id
|
WHERE c.title LIKE ?
|
||||||
WHERE c.title LIKE ? OR m.content LIKE ?
|
OR EXISTS (SELECT 1 FROM messages m WHERE m.conversation_id = c.id AND m.content LIKE ?)
|
||||||
ORDER BY c.updated_at DESC
|
ORDER BY c.updated_at DESC
|
||||||
LIMIT ? OFFSET ?`,
|
LIMIT ? OFFSET ?`,
|
||||||
searchPattern, searchPattern, limit, offset,
|
searchPattern, searchPattern, limit, offset,
|
||||||
@@ -457,6 +457,19 @@ func (db *DB) GetReActData(conversationID string) (reactInput, reactOutput strin
|
|||||||
return reactInput, reactOutput, nil
|
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 添加消息
|
// AddMessage 添加消息
|
||||||
func (db *DB) AddMessage(conversationID, role, content string, mcpExecutionIDs []string) (*Message, error) {
|
func (db *DB) AddMessage(conversationID, role, content string, mcpExecutionIDs []string) (*Message, error) {
|
||||||
id := uuid.New().String()
|
id := uuid.New().String()
|
||||||
@@ -540,6 +553,102 @@ func (db *DB) GetMessages(conversationID string) ([]Message, error) {
|
|||||||
return messages, nil
|
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 过程详情事件
|
// ProcessDetail 过程详情事件
|
||||||
type ProcessDetail struct {
|
type ProcessDetail struct {
|
||||||
ID string `json:"id"`
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -403,6 +403,35 @@ func (db *DB) UpdateGroupPinned(id string, pinned bool) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GroupMapping 分组映射关系
|
||||||
|
type GroupMapping struct {
|
||||||
|
ConversationID string `json:"conversationId"`
|
||||||
|
GroupID string `json:"groupId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllGroupMappings 批量获取所有分组映射(消除 N+1 查询)
|
||||||
|
func (db *DB) GetAllGroupMappings() ([]GroupMapping, error) {
|
||||||
|
rows, err := db.Query("SELECT conversation_id, group_id FROM conversation_group_mappings")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("查询分组映射失败: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var mappings []GroupMapping
|
||||||
|
for rows.Next() {
|
||||||
|
var m GroupMapping
|
||||||
|
if err := rows.Scan(&m.ConversationID, &m.GroupID); err != nil {
|
||||||
|
return nil, fmt.Errorf("扫描分组映射失败: %w", err)
|
||||||
|
}
|
||||||
|
mappings = append(mappings, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
if mappings == nil {
|
||||||
|
mappings = []GroupMapping{}
|
||||||
|
}
|
||||||
|
return mappings, nil
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateConversationPinnedInGroup 更新对话在分组中的置顶状态
|
// UpdateConversationPinnedInGroup 更新对话在分组中的置顶状态
|
||||||
func (db *DB) UpdateConversationPinnedInGroup(conversationID, groupID string, pinned bool) error {
|
func (db *DB) UpdateConversationPinnedInGroup(conversationID, groupID string, pinned bool) error {
|
||||||
pinnedValue := 0
|
pinnedValue := 0
|
||||||
|
|||||||
@@ -108,7 +108,13 @@ func runMCPToolInvocation(
|
|||||||
var args map[string]interface{}
|
var args map[string]interface{}
|
||||||
if argumentsInJSON != "" && argumentsInJSON != "null" {
|
if argumentsInJSON != "" && argumentsInJSON != "null" {
|
||||||
if err := json.Unmarshal([]byte(argumentsInJSON), &args); err != nil {
|
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 {
|
if args == nil {
|
||||||
@@ -154,13 +160,17 @@ func runMCPToolInvocation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UnknownToolReminderHandler 供 compose.ToolsNodeConfig.UnknownToolsHandler 使用:
|
// UnknownToolReminderHandler 供 compose.ToolsNodeConfig.UnknownToolsHandler 使用:
|
||||||
// 模型请求了未注册的工具名时,仅返回说明性文本,error 恒为 nil,以便 ReAct 继续迭代而不中断图执行。
|
// 模型请求了未注册的工具名时,返回一个「可恢复」的错误,让上层 runner 触发重试与纠错提示,
|
||||||
|
// 同时避免 UI 永远停留在“执行中”(runner 会在 recoverable 分支 flush 掉 pending 的 tool_call)。
|
||||||
// 不进行名称猜测或映射,避免误执行。
|
// 不进行名称猜测或映射,避免误执行。
|
||||||
func UnknownToolReminderHandler() func(ctx context.Context, name, input string) (string, error) {
|
func UnknownToolReminderHandler() func(ctx context.Context, name, input string) (string, error) {
|
||||||
return func(ctx context.Context, name, input string) (string, error) {
|
return func(ctx context.Context, name, input string) (string, error) {
|
||||||
_ = ctx
|
_ = ctx
|
||||||
_ = input
|
_ = input
|
||||||
return unknownToolReminderText(strings.TrimSpace(name)), nil
|
requested := strings.TrimSpace(name)
|
||||||
|
// Return a recoverable error that still carries a friendly, bilingual hint.
|
||||||
|
// This will be caught by multiagent runner as "tool not found" and trigger a retry.
|
||||||
|
return "", fmt.Errorf("tool %q not found: %s", requested, unknownToolReminderText(requested))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1256,7 +1256,7 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
|
|||||||
|
|
||||||
// 保存用户消息:有附件时一并保存附件名与路径,刷新后显示、继续对话时大模型也能从历史中拿到路径
|
// 保存用户消息:有附件时一并保存附件名与路径,刷新后显示、继续对话时大模型也能从历史中拿到路径
|
||||||
userContent := userMessageContentForStorage(req.Message, req.Attachments, savedPaths)
|
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 {
|
if err != nil {
|
||||||
h.logger.Error("保存用户消息失败", zap.Error(err))
|
h.logger.Error("保存用户消息失败", zap.Error(err))
|
||||||
}
|
}
|
||||||
@@ -1275,6 +1275,14 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
|
|||||||
assistantMessageID = assistantMsg.ID
|
assistantMessageID = assistantMsg.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 尽早下发消息 ID,便于前端在流式结束前挂上「删除本轮」等(无需等整段结束再刷新)
|
||||||
|
if userMsgRow != nil {
|
||||||
|
sendEvent("message_saved", "", map[string]interface{}{
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"userMessageId": userMsgRow.ID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 创建进度回调函数,复用统一逻辑
|
// 创建进度回调函数,复用统一逻辑
|
||||||
progressCallback := h.createProgressCallback(conversationID, assistantMessageID, sendEvent)
|
progressCallback := h.createProgressCallback(conversationID, assistantMessageID, sendEvent)
|
||||||
|
|
||||||
|
|||||||
@@ -93,22 +93,27 @@ func (h *ChatUploadsHandler) List(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
var files []ChatUploadFileItem
|
var files []ChatUploadFileItem
|
||||||
|
var folders []string
|
||||||
err = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
|
err = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
|
||||||
if walkErr != nil {
|
if walkErr != nil {
|
||||||
return walkErr
|
return walkErr
|
||||||
}
|
}
|
||||||
|
rel, err := filepath.Rel(root, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if rel == "." {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
relSlash := filepath.ToSlash(rel)
|
||||||
if d.IsDir() {
|
if d.IsDir() {
|
||||||
|
folders = append(folders, relSlash)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
info, err := d.Info()
|
info, err := d.Info()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
rel, err := filepath.Rel(root, path)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
relSlash := filepath.ToSlash(rel)
|
|
||||||
parts := strings.Split(relSlash, "/")
|
parts := strings.Split(relSlash, "/")
|
||||||
var dateStr, convID string
|
var dateStr, convID string
|
||||||
if len(parts) >= 2 {
|
if len(parts) >= 2 {
|
||||||
@@ -142,10 +147,31 @@ func (h *ChatUploadsHandler) List(c *gin.Context) {
|
|||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
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 {
|
sort.Slice(files, func(i, j int) bool {
|
||||||
return files[i].ModifiedUnix > files[j].ModifiedUnix
|
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=...
|
// Download GET /api/chat-uploads/download?path=...
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -754,6 +756,137 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": "配置已更新"})
|
c.JSON(http.StatusOK, gin.H{"message": "配置已更新"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestOpenAIRequest 测试OpenAI连接请求
|
||||||
|
type TestOpenAIRequest struct {
|
||||||
|
BaseURL string `json:"base_url"`
|
||||||
|
APIKey string `json:"api_key"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOpenAI 测试OpenAI API连接是否可用
|
||||||
|
func (h *ConfigHandler) TestOpenAI(c *gin.Context) {
|
||||||
|
var req TestOpenAIRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的请求参数: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(req.APIKey) == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "API Key 不能为空"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.Model) == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "模型不能为空"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
baseURL := strings.TrimSuffix(strings.TrimSpace(req.BaseURL), "/")
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = "https://api.openai.com/v1"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构造一个最小的 chat completion 请求
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"model": req.Model,
|
||||||
|
"messages": []map[string]string{
|
||||||
|
{"role": "user", "content": "Hi"},
|
||||||
|
},
|
||||||
|
"max_tokens": 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "构造请求失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/chat/completions", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "构造HTTP请求失败: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("Content-Type", "application/json")
|
||||||
|
httpReq.Header.Set("Authorization", "Bearer "+strings.TrimSpace(req.APIKey))
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
resp, err := http.DefaultClient.Do(httpReq)
|
||||||
|
latency := time.Since(start)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"error": "连接失败: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
// 尝试提取错误信息
|
||||||
|
var errResp struct {
|
||||||
|
Error struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
} `json:"error"`
|
||||||
|
}
|
||||||
|
errMsg := string(respBody)
|
||||||
|
if json.Unmarshal(respBody, &errResp) == nil && errResp.Error.Message != "" {
|
||||||
|
errMsg = errResp.Error.Message
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"error": fmt.Sprintf("API 返回错误 (HTTP %d): %s", resp.StatusCode, errMsg),
|
||||||
|
"status_code": resp.StatusCode,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析响应并严格验证是否为有效的 chat completion 响应
|
||||||
|
var chatResp struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Object string `json:"object"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
Choices []struct {
|
||||||
|
Message struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
} `json:"message"`
|
||||||
|
} `json:"choices"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(respBody, &chatResp); err != nil {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"error": "API 响应不是有效的 JSON,请检查 Base URL 是否正确",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 严格校验:必须包含 choices 且有 assistant 回复
|
||||||
|
if len(chatResp.Choices) == 0 {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"error": "API 响应缺少 choices 字段,请检查 Base URL 路径是否正确(通常以 /v1 结尾)",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if chatResp.ID == "" && chatResp.Model == "" {
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": false,
|
||||||
|
"error": "API 响应格式不符合 OpenAI 规范,请检查 Base URL 是否正确",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"success": true,
|
||||||
|
"model": chatResp.Model,
|
||||||
|
"latency_ms": latency.Milliseconds(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ApplyConfig 应用配置(重新加载并重启相关服务)
|
// ApplyConfig 应用配置(重新加载并重启相关服务)
|
||||||
func (h *ConfigHandler) ApplyConfig(c *gin.Context) {
|
func (h *ConfigHandler) ApplyConfig(c *gin.Context) {
|
||||||
// 先检查是否需要动态初始化知识库(在锁外执行,避免阻塞其他请求)
|
// 先检查是否需要动态初始化知识库(在锁外执行,避免阻塞其他请求)
|
||||||
|
|||||||
@@ -190,3 +190,44 @@ func (h *ConversationHandler) DeleteConversation(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"message": "删除成功"})
|
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",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -234,6 +234,18 @@ func (h *GroupHandler) GetGroupConversations(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, groupConvs)
|
c.JSON(http.StatusOK, groupConvs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetAllMappings 批量获取所有分组映射(消除前端 N+1 请求)
|
||||||
|
func (h *GroupHandler) GetAllMappings(c *gin.Context) {
|
||||||
|
mappings, err := h.db.GetAllGroupMappings()
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("获取分组映射失败", zap.Error(err))
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, mappings)
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateConversationPinnedRequest 更新对话置顶状态请求
|
// UpdateConversationPinnedRequest 更新对话置顶状态请求
|
||||||
type UpdateConversationPinnedRequest struct {
|
type UpdateConversationPinnedRequest struct {
|
||||||
Pinned bool `json:"pinned"`
|
Pinned bool `json:"pinned"`
|
||||||
|
|||||||
@@ -246,6 +246,41 @@ func (h *MonitorHandler) GetExecution(c *gin.Context) {
|
|||||||
c.JSON(http.StatusNotFound, gin.H{"error": "执行记录未找到"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "执行记录未找到"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BatchGetToolNames 批量获取工具执行的工具名称(消除前端 N+1 请求)
|
||||||
|
func (h *MonitorHandler) BatchGetToolNames(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
IDs []string `json:"ids"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make(map[string]string, len(req.IDs))
|
||||||
|
for _, id := range req.IDs {
|
||||||
|
// 先从内部MCP服务器查找
|
||||||
|
if exec, exists := h.mcpServer.GetExecution(id); exists {
|
||||||
|
result[id] = exec.ToolName
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 再从外部MCP管理器查找
|
||||||
|
if h.externalMCPMgr != nil {
|
||||||
|
if exec, exists := h.externalMCPMgr.GetExecution(id); exists {
|
||||||
|
result[id] = exec.ToolName
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 最后从数据库查找
|
||||||
|
if h.db != nil {
|
||||||
|
if exec, err := h.db.GetToolExecution(id); err == nil && exec != nil {
|
||||||
|
result[id] = exec.ToolName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
// GetStats 获取统计信息
|
// GetStats 获取统计信息
|
||||||
func (h *MonitorHandler) GetStats(c *gin.Context) {
|
func (h *MonitorHandler) GetStats(c *gin.Context) {
|
||||||
stats := h.loadStats()
|
stats := h.loadStats()
|
||||||
|
|||||||
@@ -103,6 +103,13 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
|
|||||||
conversationID := prep.ConversationID
|
conversationID := prep.ConversationID
|
||||||
assistantMessageID := prep.AssistantMessageID
|
assistantMessageID := prep.AssistantMessageID
|
||||||
|
|
||||||
|
if prep.UserMessageID != "" {
|
||||||
|
sendEvent("message_saved", "", map[string]interface{}{
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"userMessageId": prep.UserMessageID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
progressCallback := h.createProgressCallback(conversationID, assistantMessageID, sendEvent)
|
progressCallback := h.createProgressCallback(conversationID, assistantMessageID, sendEvent)
|
||||||
|
|
||||||
baseCtx, cancelWithCause := context.WithCancelCause(context.Background())
|
baseCtx, cancelWithCause := context.WithCancelCause(context.Background())
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ type multiAgentPrepared struct {
|
|||||||
FinalMessage string
|
FinalMessage string
|
||||||
RoleTools []string
|
RoleTools []string
|
||||||
AssistantMessageID string
|
AssistantMessageID string
|
||||||
|
UserMessageID string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest) (*multiAgentPrepared, error) {
|
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)
|
finalMessage = appendAttachmentsToMessage(finalMessage, req.Attachments, savedPaths)
|
||||||
|
|
||||||
userContent := userMessageContentForStorage(req.Message, req.Attachments, savedPaths)
|
userContent := userMessageContentForStorage(req.Message, req.Attachments, savedPaths)
|
||||||
if _, err = h.db.AddMessage(conversationID, "user", userContent, nil); err != nil {
|
userMsgRow, uerr := h.db.AddMessage(conversationID, "user", userContent, nil)
|
||||||
h.logger.Error("保存用户消息失败", zap.Error(err))
|
if uerr != nil {
|
||||||
return nil, fmt.Errorf("保存用户消息失败: %w", err)
|
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)
|
assistantMsg, aerr := h.db.AddMessage(conversationID, "assistant", "处理中...", nil)
|
||||||
@@ -129,5 +135,6 @@ func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest) (*multiAgentPr
|
|||||||
FinalMessage: finalMessage,
|
FinalMessage: finalMessage,
|
||||||
RoleTools: roleTools,
|
RoleTools: roleTools,
|
||||||
AssistantMessageID: assistantMessageID,
|
AssistantMessageID: assistantMessageID,
|
||||||
|
UserMessageID: userMessageID,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
defer cancel()
|
||||||
|
|
||||||
s.logger.Info("开始执行工具",
|
s.logger.Info("开始执行工具",
|
||||||
|
|||||||
+180
-29
@@ -36,6 +36,16 @@ type RunResult struct {
|
|||||||
LastReActOutput string
|
LastReActOutput string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// toolCallPendingInfo tracks a tool_call emitted to the UI so we can later
|
||||||
|
// correlate tool_result events (even when the framework omits ToolCallID) and
|
||||||
|
// avoid leaving the UI stuck in "running" state on recoverable errors.
|
||||||
|
type toolCallPendingInfo struct {
|
||||||
|
ToolCallID string
|
||||||
|
ToolName string
|
||||||
|
EinoAgent string
|
||||||
|
EinoRole string
|
||||||
|
}
|
||||||
|
|
||||||
// RunDeepAgent 使用 Eino DeepAgent 执行一轮对话(流式事件通过 progress 回调输出)。
|
// RunDeepAgent 使用 Eino DeepAgent 执行一轮对话(流式事件通过 progress 回调输出)。
|
||||||
func RunDeepAgent(
|
func RunDeepAgent(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
@@ -223,6 +233,9 @@ func RunDeepAgent(
|
|||||||
ToolsNodeConfig: compose.ToolsNodeConfig{
|
ToolsNodeConfig: compose.ToolsNodeConfig{
|
||||||
Tools: subTools,
|
Tools: subTools,
|
||||||
UnknownToolsHandler: einomcp.UnknownToolReminderHandler(),
|
UnknownToolsHandler: einomcp.UnknownToolReminderHandler(),
|
||||||
|
ToolCallMiddlewares: []compose.ToolMiddleware{
|
||||||
|
{Invokable: softRecoveryToolCallMiddleware()},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
EmitInternalEvents: true,
|
EmitInternalEvents: true,
|
||||||
},
|
},
|
||||||
@@ -278,6 +291,9 @@ func RunDeepAgent(
|
|||||||
ToolsNodeConfig: compose.ToolsNodeConfig{
|
ToolsNodeConfig: compose.ToolsNodeConfig{
|
||||||
Tools: mainTools,
|
Tools: mainTools,
|
||||||
UnknownToolsHandler: einomcp.UnknownToolReminderHandler(),
|
UnknownToolsHandler: einomcp.UnknownToolReminderHandler(),
|
||||||
|
ToolCallMiddlewares: []compose.ToolMiddleware{
|
||||||
|
{Invokable: softRecoveryToolCallMiddleware()},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
EmitInternalEvents: true,
|
EmitInternalEvents: true,
|
||||||
},
|
},
|
||||||
@@ -302,34 +318,20 @@ func RunDeepAgent(
|
|||||||
var lastRunMsgs []adk.Message
|
var lastRunMsgs []adk.Message
|
||||||
var lastAssistant string
|
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:
|
attemptLoop:
|
||||||
for attempt := 0; attempt < maxToolCallArgumentsJSONAttempts; attempt++ {
|
for attempt := 0; attempt < maxToolCallRecoveryAttempts; attempt++ {
|
||||||
msgs := make([]adk.Message, 0, len(baseMsgs)+attempt)
|
msgs := make([]adk.Message, 0, len(baseMsgs)+len(retryHints))
|
||||||
msgs = append(msgs, baseMsgs...)
|
msgs = append(msgs, baseMsgs...)
|
||||||
for i := 0; i < attempt; i++ {
|
msgs = append(msgs, retryHints...)
|
||||||
msgs = append(msgs, toolCallArgumentsJSONRetryHint())
|
|
||||||
}
|
|
||||||
|
|
||||||
if attempt > 0 {
|
if attempt > 0 {
|
||||||
mcpIDsMu.Lock()
|
mcpIDsMu.Lock()
|
||||||
mcpIDs = mcpIDs[:0]
|
mcpIDs = mcpIDs[:0]
|
||||||
mcpIDsMu.Unlock()
|
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 输出;每轮重试重置,避免拼接失败轮次的片段。
|
// 仅保留主代理最后一次 assistant 输出;每轮重试重置,避免拼接失败轮次的片段。
|
||||||
@@ -340,6 +342,69 @@ attemptLoop:
|
|||||||
var einoMainRound int
|
var einoMainRound int
|
||||||
var einoLastAgent string
|
var einoLastAgent string
|
||||||
subAgentToolStep := make(map[string]int)
|
subAgentToolStep := make(map[string]int)
|
||||||
|
// Track tool calls emitted in this attempt so we can:
|
||||||
|
// - attach toolCallId to tool_result when framework omits it
|
||||||
|
// - flush running tool calls as failed when a recoverable tool execution error happens
|
||||||
|
pendingByID := make(map[string]toolCallPendingInfo)
|
||||||
|
pendingQueueByAgent := make(map[string][]string)
|
||||||
|
markPending := func(tc toolCallPendingInfo) {
|
||||||
|
if tc.ToolCallID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pendingByID[tc.ToolCallID] = tc
|
||||||
|
pendingQueueByAgent[tc.EinoAgent] = append(pendingQueueByAgent[tc.EinoAgent], tc.ToolCallID)
|
||||||
|
}
|
||||||
|
popNextPendingForAgent := func(agentName string) (toolCallPendingInfo, bool) {
|
||||||
|
q := pendingQueueByAgent[agentName]
|
||||||
|
for len(q) > 0 {
|
||||||
|
id := q[0]
|
||||||
|
q = q[1:]
|
||||||
|
pendingQueueByAgent[agentName] = q
|
||||||
|
if tc, ok := pendingByID[id]; ok {
|
||||||
|
delete(pendingByID, id)
|
||||||
|
return tc, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return toolCallPendingInfo{}, false
|
||||||
|
}
|
||||||
|
removePendingByID := func(toolCallID string) {
|
||||||
|
if toolCallID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(pendingByID, toolCallID)
|
||||||
|
// queue cleanup is lazy in popNextPendingForAgent
|
||||||
|
}
|
||||||
|
flushAllPendingAsFailed := func(err error) {
|
||||||
|
if progress == nil {
|
||||||
|
pendingByID = make(map[string]toolCallPendingInfo)
|
||||||
|
pendingQueueByAgent = make(map[string][]string)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg := ""
|
||||||
|
if err != nil {
|
||||||
|
msg = err.Error()
|
||||||
|
}
|
||||||
|
for _, tc := range pendingByID {
|
||||||
|
toolName := tc.ToolName
|
||||||
|
if strings.TrimSpace(toolName) == "" {
|
||||||
|
toolName = "unknown"
|
||||||
|
}
|
||||||
|
progress("tool_result", fmt.Sprintf("工具结果 (%s)", toolName), map[string]interface{}{
|
||||||
|
"toolName": toolName,
|
||||||
|
"success": false,
|
||||||
|
"isError": true,
|
||||||
|
"result": msg,
|
||||||
|
"resultPreview": msg,
|
||||||
|
"toolCallId": tc.ToolCallID,
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"einoAgent": tc.EinoAgent,
|
||||||
|
"einoRole": tc.EinoRole,
|
||||||
|
"source": "eino",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pendingByID = make(map[string]toolCallPendingInfo)
|
||||||
|
pendingQueueByAgent = make(map[string][]string)
|
||||||
|
}
|
||||||
|
|
||||||
runner := adk.NewRunner(ctx, adk.RunnerConfig{
|
runner := adk.NewRunner(ctx, adk.RunnerConfig{
|
||||||
Agent: da,
|
Agent: da,
|
||||||
@@ -357,12 +422,52 @@ attemptLoop:
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if ev.Err != nil {
|
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 {
|
if logger != nil {
|
||||||
logger.Warn("eino: recoverable tool-call JSON error from model/API", zap.Error(ev.Err), zap.Int("attempt", attempt))
|
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
|
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))
|
||||||
|
}
|
||||||
|
// Ensure UI/tool timeline doesn't get stuck at "running" for tool calls that
|
||||||
|
// will never receive a proper tool_result due to the recoverable error.
|
||||||
|
flushAllPendingAsFailed(ev.Err)
|
||||||
|
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.
|
||||||
|
flushAllPendingAsFailed(ev.Err)
|
||||||
if progress != nil {
|
if progress != nil {
|
||||||
progress("error", ev.Err.Error(), map[string]interface{}{
|
progress("error", ev.Err.Error(), map[string]interface{}{
|
||||||
"conversationId": conversationID,
|
"conversationId": conversationID,
|
||||||
@@ -513,7 +618,7 @@ attemptLoop:
|
|||||||
if merged := mergeStreamingToolCallFragments(toolStreamFragments); len(merged) > 0 {
|
if merged := mergeStreamingToolCallFragments(toolStreamFragments); len(merged) > 0 {
|
||||||
lastToolChunk = &schema.Message{ToolCalls: merged}
|
lastToolChunk = &schema.Message{ToolCalls: merged}
|
||||||
}
|
}
|
||||||
tryEmitToolCallsOnce(lastToolChunk, ev.AgentName, orchestratorName, conversationID, progress, toolEmitSeen, subAgentToolStep)
|
tryEmitToolCallsOnce(lastToolChunk, ev.AgentName, orchestratorName, conversationID, progress, toolEmitSeen, subAgentToolStep, markPending)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -521,7 +626,7 @@ attemptLoop:
|
|||||||
if gerr != nil || msg == nil {
|
if gerr != nil || msg == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
tryEmitToolCallsOnce(mergeMessageToolCalls(msg), ev.AgentName, orchestratorName, conversationID, progress, toolEmitSeen, subAgentToolStep)
|
tryEmitToolCallsOnce(mergeMessageToolCalls(msg), ev.AgentName, orchestratorName, conversationID, progress, toolEmitSeen, subAgentToolStep, markPending)
|
||||||
|
|
||||||
if mv.Role == schema.Assistant {
|
if mv.Role == schema.Assistant {
|
||||||
if progress != nil && strings.TrimSpace(msg.ReasoningContent) != "" {
|
if progress != nil && strings.TrimSpace(msg.ReasoningContent) != "" {
|
||||||
@@ -589,8 +694,31 @@ attemptLoop:
|
|||||||
"einoRole": einoRoleTag(ev.AgentName),
|
"einoRole": einoRoleTag(ev.AgentName),
|
||||||
"source": "eino",
|
"source": "eino",
|
||||||
}
|
}
|
||||||
if msg.ToolCallID != "" {
|
toolCallID := strings.TrimSpace(msg.ToolCallID)
|
||||||
data["toolCallId"] = msg.ToolCallID
|
// Some framework paths (e.g. UnknownToolsHandler) may omit ToolCallID on tool messages.
|
||||||
|
// Infer from the tool_call emission order for this agent to keep UI state consistent.
|
||||||
|
if toolCallID == "" {
|
||||||
|
// In some internal tool execution paths, ev.AgentName may be empty for tool-role
|
||||||
|
// messages. Try several fallbacks to avoid leaving UI tool_call status stuck.
|
||||||
|
if inferred, ok := popNextPendingForAgent(ev.AgentName); ok {
|
||||||
|
toolCallID = inferred.ToolCallID
|
||||||
|
} else if inferred, ok := popNextPendingForAgent(orchestratorName); ok {
|
||||||
|
toolCallID = inferred.ToolCallID
|
||||||
|
} else if inferred, ok := popNextPendingForAgent(""); ok {
|
||||||
|
toolCallID = inferred.ToolCallID
|
||||||
|
} else {
|
||||||
|
// last resort: pick any pending toolCallID
|
||||||
|
for id := range pendingByID {
|
||||||
|
toolCallID = id
|
||||||
|
delete(pendingByID, id)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
removePendingByID(toolCallID)
|
||||||
|
}
|
||||||
|
if toolCallID != "" {
|
||||||
|
data["toolCallId"] = toolCallID
|
||||||
}
|
}
|
||||||
progress("tool_result", fmt.Sprintf("工具结果 (%s)", toolName), data)
|
progress("tool_result", fmt.Sprintf("工具结果 (%s)", toolName), data)
|
||||||
}
|
}
|
||||||
@@ -733,7 +861,14 @@ func toolCallsRichSignature(msg *schema.Message) string {
|
|||||||
return base + "|" + strings.Join(parts, ";")
|
return base + "|" + strings.Join(parts, ";")
|
||||||
}
|
}
|
||||||
|
|
||||||
func tryEmitToolCallsOnce(msg *schema.Message, agentName, orchestratorName, conversationID string, progress func(string, string, interface{}), seen map[string]struct{}, subAgentToolStep map[string]int) {
|
func tryEmitToolCallsOnce(
|
||||||
|
msg *schema.Message,
|
||||||
|
agentName, orchestratorName, conversationID string,
|
||||||
|
progress func(string, string, interface{}),
|
||||||
|
seen map[string]struct{},
|
||||||
|
subAgentToolStep map[string]int,
|
||||||
|
markPending func(toolCallPendingInfo),
|
||||||
|
) {
|
||||||
if msg == nil || len(msg.ToolCalls) == 0 || progress == nil || seen == nil {
|
if msg == nil || len(msg.ToolCalls) == 0 || progress == nil || seen == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -745,10 +880,16 @@ func tryEmitToolCallsOnce(msg *schema.Message, agentName, orchestratorName, conv
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
seen[sig] = struct{}{}
|
seen[sig] = struct{}{}
|
||||||
emitToolCallsFromMessage(msg, agentName, orchestratorName, conversationID, progress, subAgentToolStep)
|
emitToolCallsFromMessage(msg, agentName, orchestratorName, conversationID, progress, subAgentToolStep, markPending)
|
||||||
}
|
}
|
||||||
|
|
||||||
func emitToolCallsFromMessage(msg *schema.Message, agentName, orchestratorName, conversationID string, progress func(string, string, interface{}), subAgentToolStep map[string]int) {
|
func emitToolCallsFromMessage(
|
||||||
|
msg *schema.Message,
|
||||||
|
agentName, orchestratorName, conversationID string,
|
||||||
|
progress func(string, string, interface{}),
|
||||||
|
subAgentToolStep map[string]int,
|
||||||
|
markPending func(toolCallPendingInfo),
|
||||||
|
) {
|
||||||
if msg == nil || len(msg.ToolCalls) == 0 || progress == nil {
|
if msg == nil || len(msg.ToolCalls) == 0 || progress == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -797,6 +938,16 @@ func emitToolCallsFromMessage(msg *schema.Message, agentName, orchestratorName,
|
|||||||
if toolCallID == "" && tc.Index != nil {
|
if toolCallID == "" && tc.Index != nil {
|
||||||
toolCallID = fmt.Sprintf("eino-stream-%d", *tc.Index)
|
toolCallID = fmt.Sprintf("eino-stream-%d", *tc.Index)
|
||||||
}
|
}
|
||||||
|
// Record pending tool calls for later tool_result correlation / recovery flushing.
|
||||||
|
// We intentionally record even for unknown tools to avoid "running" badge getting stuck.
|
||||||
|
if markPending != nil && toolCallID != "" {
|
||||||
|
markPending(toolCallPendingInfo{
|
||||||
|
ToolCallID: toolCallID,
|
||||||
|
ToolName: display,
|
||||||
|
EinoAgent: agentName,
|
||||||
|
EinoRole: role,
|
||||||
|
})
|
||||||
|
}
|
||||||
progress("tool_call", fmt.Sprintf("正在调用工具: %s", display), map[string]interface{}{
|
progress("tool_call", fmt.Sprintf("正在调用工具: %s", display), map[string]interface{}{
|
||||||
"toolName": display,
|
"toolName": display,
|
||||||
"arguments": argStr,
|
"arguments": argStr,
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ import (
|
|||||||
"github.com/cloudwego/eino/schema"
|
"github.com/cloudwego/eino/schema"
|
||||||
)
|
)
|
||||||
|
|
||||||
// maxToolCallArgumentsJSONAttempts 含首次运行:首次 + 自动重试次数。
|
// maxToolCallRecoveryAttempts 含首次运行:首次 + 自动重试次数。
|
||||||
// 例如为 3 表示最多共 3 次完整 DeepAgent 运行(2 次失败后各追加一条纠错提示)。
|
// 例如为 3 表示最多共 3 次完整 DeepAgent 运行(2 次失败后各追加一条纠错提示)。
|
||||||
const maxToolCallArgumentsJSONAttempts = 3
|
// 该常量同时用于 JSON 参数错误和工具执行错误(如子代理名称不存在)的恢复重试。
|
||||||
|
const maxToolCallRecoveryAttempts = 5
|
||||||
|
|
||||||
// toolCallArgumentsJSONRetryHint 追加在用户消息后,提示模型输出合法 JSON 工具参数(部分云厂商会在流式阶段校验 arguments)。
|
// toolCallArgumentsJSONRetryHint 追加在用户消息后,提示模型输出合法 JSON 工具参数(部分云厂商会在流式阶段校验 arguments)。
|
||||||
func toolCallArgumentsJSONRetryHint() *schema.Message {
|
func toolCallArgumentsJSONRetryHint() *schema.Message {
|
||||||
@@ -24,7 +25,7 @@ func toolCallArgumentsJSONRecoveryTimelineMessage(attempt int) string {
|
|||||||
"接口拒绝了无效的工具参数 JSON。已向对话追加系统提示并要求模型重新生成合法的 function.arguments。"+
|
"接口拒绝了无效的工具参数 JSON。已向对话追加系统提示并要求模型重新生成合法的 function.arguments。"+
|
||||||
"当前为第 %d/%d 轮完整运行。\n\n"+
|
"当前为第 %d/%d 轮完整运行。\n\n"+
|
||||||
"The API rejected invalid JSON in tool arguments. A system hint was appended. This is full run %d of %d.",
|
"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,131 @@
|
|||||||
|
package multiagent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/cloudwego/eino/compose"
|
||||||
|
)
|
||||||
|
|
||||||
|
// softRecoveryToolCallMiddleware returns an InvokableToolMiddleware that catches
|
||||||
|
// specific recoverable errors from tool execution (JSON parse errors, tool-not-found,
|
||||||
|
// etc.) and converts them into soft errors: nil error + descriptive error content
|
||||||
|
// returned to the LLM. This allows the model to self-correct within the same
|
||||||
|
// iteration rather than crashing the entire graph and requiring a full replay.
|
||||||
|
//
|
||||||
|
// Without this middleware, a JSON parse failure in any tool's InvokableRun propagates
|
||||||
|
// as a hard error through the Eino ToolsNode → [NodeRunError] → ev.Err, which
|
||||||
|
// either triggers the full-replay retry loop (expensive) or terminates the run
|
||||||
|
// entirely once retries are exhausted. With it, the LLM simply sees an error message
|
||||||
|
// in the tool result and can adjust its next tool call accordingly.
|
||||||
|
func softRecoveryToolCallMiddleware() compose.InvokableToolMiddleware {
|
||||||
|
return func(next compose.InvokableToolEndpoint) compose.InvokableToolEndpoint {
|
||||||
|
return func(ctx context.Context, input *compose.ToolInput) (*compose.ToolOutput, error) {
|
||||||
|
output, err := next(ctx, input)
|
||||||
|
if err == nil {
|
||||||
|
return output, nil
|
||||||
|
}
|
||||||
|
if !isSoftRecoverableToolError(err) {
|
||||||
|
return output, err
|
||||||
|
}
|
||||||
|
// Convert the hard error into a soft error: the LLM will see this
|
||||||
|
// message as the tool's output and can self-correct.
|
||||||
|
msg := buildSoftRecoveryMessage(input.Name, input.Arguments, err)
|
||||||
|
return &compose.ToolOutput{Result: msg}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isSoftRecoverableToolError determines whether a tool execution error should be
|
||||||
|
// silently converted to a tool-result message rather than crashing the graph.
|
||||||
|
func isSoftRecoverableToolError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
s := strings.ToLower(err.Error())
|
||||||
|
|
||||||
|
// JSON unmarshal/parse failures — the model generated truncated or malformed arguments.
|
||||||
|
if isJSONRelatedError(s) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
if strings.Contains(s, "tool") && strings.Contains(s, "not found") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// isJSONRelatedError checks whether an error string indicates a JSON parsing problem.
|
||||||
|
func isJSONRelatedError(lower string) bool {
|
||||||
|
if !strings.Contains(lower, "json") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
jsonIndicators := []string{
|
||||||
|
"unexpected end of json",
|
||||||
|
"unmarshal",
|
||||||
|
"invalid character",
|
||||||
|
"cannot unmarshal",
|
||||||
|
"invalid tool arguments",
|
||||||
|
"failed to unmarshal",
|
||||||
|
"must be in json format",
|
||||||
|
"unexpected eof",
|
||||||
|
}
|
||||||
|
for _, ind := range jsonIndicators {
|
||||||
|
if strings.Contains(lower, ind) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSoftRecoveryMessage creates a bilingual error message that the LLM can act on.
|
||||||
|
func buildSoftRecoveryMessage(toolName, arguments string, err error) string {
|
||||||
|
// Truncate arguments preview to avoid flooding the context.
|
||||||
|
argPreview := arguments
|
||||||
|
if len(argPreview) > 300 {
|
||||||
|
argPreview = argPreview[:300] + "... (truncated)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to determine if it's specifically a JSON parse error for a friendlier message.
|
||||||
|
errStr := err.Error()
|
||||||
|
var jsonErr *json.SyntaxError
|
||||||
|
isJSONErr := strings.Contains(strings.ToLower(errStr), "json") ||
|
||||||
|
strings.Contains(strings.ToLower(errStr), "unmarshal")
|
||||||
|
_ = jsonErr // suppress unused
|
||||||
|
|
||||||
|
if isJSONErr {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"[Tool Error] The arguments for tool '%s' are not valid JSON and could not be parsed.\n"+
|
||||||
|
"Error: %s\n"+
|
||||||
|
"Arguments received: %s\n\n"+
|
||||||
|
"Please fix the JSON (ensure double-quoted keys, matched braces/brackets, no trailing commas, "+
|
||||||
|
"no truncation) and call the tool again.\n\n"+
|
||||||
|
"[工具错误] 工具 '%s' 的参数不是合法 JSON,无法解析。\n"+
|
||||||
|
"错误:%s\n"+
|
||||||
|
"收到的参数:%s\n\n"+
|
||||||
|
"请修正 JSON(确保双引号键名、括号配对、无尾部逗号、无截断),然后重新调用工具。",
|
||||||
|
toolName, errStr, argPreview,
|
||||||
|
toolName, errStr, argPreview,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"[Tool Error] Tool '%s' execution failed: %s\n"+
|
||||||
|
"Arguments: %s\n\n"+
|
||||||
|
"Please review the available tools and their expected arguments, then retry.\n\n"+
|
||||||
|
"[工具错误] 工具 '%s' 执行失败:%s\n"+
|
||||||
|
"参数:%s\n\n"+
|
||||||
|
"请检查可用工具及其参数要求,然后重试。",
|
||||||
|
toolName, errStr, argPreview,
|
||||||
|
toolName, errStr, argPreview,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package multiagent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/cloudwego/eino/compose"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsSoftRecoverableToolError(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nil error",
|
||||||
|
err: nil,
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unexpected end of JSON input",
|
||||||
|
err: errors.New("unexpected end of JSON input"),
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "failed to unmarshal task tool input json",
|
||||||
|
err: errors.New("failed to unmarshal task tool input json: unexpected end of JSON input"),
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid tool arguments JSON",
|
||||||
|
err: errors.New("invalid tool arguments JSON: unexpected end of JSON input"),
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "json invalid character",
|
||||||
|
err: errors.New(`invalid character '}' looking for beginning of value in JSON`),
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "subagent type not found",
|
||||||
|
err: errors.New("subagent type recon_agent not found"),
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tool not found",
|
||||||
|
err: errors.New("tool nmap_scan not found in toolsNode indexes"),
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unrelated network error",
|
||||||
|
err: errors.New("connection refused"),
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "context cancelled",
|
||||||
|
err: context.Canceled,
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "real json unmarshal error",
|
||||||
|
err: func() error {
|
||||||
|
var v map[string]interface{}
|
||||||
|
return json.Unmarshal([]byte(`{"key": `), &v)
|
||||||
|
}(),
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := isSoftRecoverableToolError(tt.err)
|
||||||
|
if got != tt.expected {
|
||||||
|
t.Errorf("isSoftRecoverableToolError(%v) = %v, want %v", tt.err, got, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSoftRecoveryToolCallMiddleware_PassesThrough(t *testing.T) {
|
||||||
|
mw := softRecoveryToolCallMiddleware()
|
||||||
|
called := false
|
||||||
|
next := func(ctx context.Context, input *compose.ToolInput) (*compose.ToolOutput, error) {
|
||||||
|
called = true
|
||||||
|
return &compose.ToolOutput{Result: "success"}, nil
|
||||||
|
}
|
||||||
|
wrapped := mw(next)
|
||||||
|
out, err := wrapped(context.Background(), &compose.ToolInput{
|
||||||
|
Name: "test_tool",
|
||||||
|
Arguments: `{"key": "value"}`,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !called {
|
||||||
|
t.Fatal("next endpoint was not called")
|
||||||
|
}
|
||||||
|
if out.Result != "success" {
|
||||||
|
t.Fatalf("expected 'success', got %q", out.Result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSoftRecoveryToolCallMiddleware_ConvertsJSONError(t *testing.T) {
|
||||||
|
mw := softRecoveryToolCallMiddleware()
|
||||||
|
next := func(ctx context.Context, input *compose.ToolInput) (*compose.ToolOutput, error) {
|
||||||
|
return nil, errors.New("failed to unmarshal task tool input json: unexpected end of JSON input")
|
||||||
|
}
|
||||||
|
wrapped := mw(next)
|
||||||
|
out, err := wrapped(context.Background(), &compose.ToolInput{
|
||||||
|
Name: "task",
|
||||||
|
Arguments: `{"subagent_type": "recon`,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected nil error (soft recovery), got: %v", err)
|
||||||
|
}
|
||||||
|
if out == nil || out.Result == "" {
|
||||||
|
t.Fatal("expected non-empty recovery message")
|
||||||
|
}
|
||||||
|
if !containsAll(out.Result, "[Tool Error]", "task", "JSON") {
|
||||||
|
t.Fatalf("recovery message missing expected content: %s", out.Result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSoftRecoveryToolCallMiddleware_PropagatesNonRecoverable(t *testing.T) {
|
||||||
|
mw := softRecoveryToolCallMiddleware()
|
||||||
|
origErr := errors.New("connection timeout to remote server")
|
||||||
|
next := func(ctx context.Context, input *compose.ToolInput) (*compose.ToolOutput, error) {
|
||||||
|
return nil, origErr
|
||||||
|
}
|
||||||
|
wrapped := mw(next)
|
||||||
|
_, err := wrapped(context.Background(), &compose.ToolInput{
|
||||||
|
Name: "test_tool",
|
||||||
|
Arguments: `{}`,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error to propagate for non-recoverable errors")
|
||||||
|
}
|
||||||
|
if err != origErr {
|
||||||
|
t.Fatalf("expected original error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsAll(s string, subs ...string) bool {
|
||||||
|
for _, sub := range subs {
|
||||||
|
if !contains(s, sub) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(s, sub string) bool {
|
||||||
|
return len(s) >= len(sub) && searchString(s, sub)
|
||||||
|
}
|
||||||
|
|
||||||
|
func searchString(s, sub string) bool {
|
||||||
|
for i := 0; i <= len(s)-len(sub); i++ {
|
||||||
|
if s[i:i+len(sub)] == sub {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -6,7 +6,9 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -16,6 +18,7 @@ import (
|
|||||||
"cyberstrike-ai/internal/mcp"
|
"cyberstrike-ai/internal/mcp"
|
||||||
"cyberstrike-ai/internal/storage"
|
"cyberstrike-ai/internal/storage"
|
||||||
|
|
||||||
|
"github.com/creack/pty"
|
||||||
"go.uber.org/zap"
|
"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...)
|
cmd := exec.CommandContext(ctx, toolConfig.Command, cmdArgs...)
|
||||||
|
applyDefaultTerminalEnv(cmd)
|
||||||
|
|
||||||
e.logger.Info("执行安全工具",
|
e.logger.Info("执行安全工具",
|
||||||
zap.String("tool", toolName),
|
zap.String("tool", toolName),
|
||||||
@@ -160,10 +164,26 @@ func (e *Executor) ExecuteTool(ctx context.Context, toolName string, args map[st
|
|||||||
// 如果上层提供了 stdout/stderr 增量回调,则边执行边读取并回调。
|
// 如果上层提供了 stdout/stderr 增量回调,则边执行边读取并回调。
|
||||||
if cb, ok := ctx.Value(ToolOutputCallbackCtxKey).(ToolOutputCallback); ok && cb != nil {
|
if cb, ok := ctx.Value(ToolOutputCallbackCtxKey).(ToolOutputCallback); ok && cb != nil {
|
||||||
output, err = streamCommandOutput(cmd, cb)
|
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 {
|
} else {
|
||||||
outputBytes, err2 := cmd.CombinedOutput()
|
outputBytes, err2 := cmd.CombinedOutput()
|
||||||
output = string(outputBytes)
|
output = string(outputBytes)
|
||||||
err = err2
|
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 {
|
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 {
|
if cb, ok := ctx.Value(ToolOutputCallbackCtxKey).(ToolOutputCallback); ok && cb != nil {
|
||||||
output, err = streamCommandOutput(cmd, cb)
|
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 {
|
} else {
|
||||||
outputBytes, err2 := cmd.CombinedOutput()
|
outputBytes, err2 := cmd.CombinedOutput()
|
||||||
output = string(outputBytes)
|
output = string(outputBytes)
|
||||||
err = err2
|
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 {
|
if err != nil {
|
||||||
e.logger.Error("系统命令执行失败",
|
e.logger.Error("系统命令执行失败",
|
||||||
@@ -1066,6 +1104,123 @@ func streamCommandOutput(cmd *exec.Cmd, cb ToolOutputCallback) (string, error) {
|
|||||||
return outBuilder.String(), waitErr
|
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 方案为类 Unix;Windows 走原逻辑
|
||||||
|
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 执行内部工具(不执行外部命令)
|
// 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,293 @@
|
|||||||
|
name: "quake_search"
|
||||||
|
command: "python3"
|
||||||
|
args:
|
||||||
|
- "-c"
|
||||||
|
- |
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import requests
|
||||||
|
import os
|
||||||
|
|
||||||
|
# ==================== Quake配置 ====================
|
||||||
|
# 请在此处配置您的Quake API Token
|
||||||
|
# 您也可以在环境变量中设置:QUAKE_API_KEY
|
||||||
|
# enable 默认为 false,需开启才能调用该MCP
|
||||||
|
QUAKE_API_KEY = "" # 请填写您的Quake API Token
|
||||||
|
# ==================================================
|
||||||
|
|
||||||
|
# Quake API基础URL
|
||||||
|
base_url = "https://quake.360.cn/api/v3/search/quake_service"
|
||||||
|
|
||||||
|
# 解析参数(从JSON字符串或命令行参数)
|
||||||
|
def parse_args():
|
||||||
|
# 尝试从第一个参数读取JSON配置
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
try:
|
||||||
|
arg1 = str(sys.argv[1])
|
||||||
|
config = json.loads(arg1)
|
||||||
|
if isinstance(config, dict):
|
||||||
|
return config
|
||||||
|
except (json.JSONDecodeError, TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 传统位置参数方式(向后兼容)
|
||||||
|
# 参数位置:query=1, size=2, start=3, fields=4, latest=5
|
||||||
|
config = {}
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
config["query"] = str(sys.argv[1])
|
||||||
|
if len(sys.argv) > 2:
|
||||||
|
try:
|
||||||
|
config["size"] = int(sys.argv[2])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
if len(sys.argv) > 3:
|
||||||
|
try:
|
||||||
|
config["start"] = int(sys.argv[3])
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
if len(sys.argv) > 4:
|
||||||
|
config["fields"] = str(sys.argv[4])
|
||||||
|
if len(sys.argv) > 5:
|
||||||
|
val = sys.argv[5]
|
||||||
|
if isinstance(val, str):
|
||||||
|
config["latest"] = val.lower() in ("true", "1", "yes")
|
||||||
|
else:
|
||||||
|
config["latest"] = bool(val)
|
||||||
|
return config
|
||||||
|
|
||||||
|
# 标准化 fields 参数:支持字符串和数组
|
||||||
|
def normalize_fields(fields_value):
|
||||||
|
if fields_value is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if isinstance(fields_value, str):
|
||||||
|
raw = fields_value.strip()
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
return [x.strip() for x in raw.split(",") if x.strip()]
|
||||||
|
|
||||||
|
if isinstance(fields_value, list):
|
||||||
|
output = []
|
||||||
|
for item in fields_value:
|
||||||
|
text = str(item).strip()
|
||||||
|
if text:
|
||||||
|
output.append(text)
|
||||||
|
return output or None
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
config = parse_args()
|
||||||
|
|
||||||
|
if not isinstance(config, dict):
|
||||||
|
error_result = {
|
||||||
|
"status": "error",
|
||||||
|
"message": f"参数解析错误: 期望字典类型,但得到 {type(config).__name__}",
|
||||||
|
"type": "TypeError"
|
||||||
|
}
|
||||||
|
print(json.dumps(error_result, ensure_ascii=False, indent=2))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
api_key = os.getenv("QUAKE_API_KEY", QUAKE_API_KEY).strip()
|
||||||
|
query = str(config.get("query", "")).strip()
|
||||||
|
|
||||||
|
if not api_key:
|
||||||
|
error_result = {
|
||||||
|
"status": "error",
|
||||||
|
"message": "缺少Quake配置: api_key(Quake API Token)",
|
||||||
|
"required_config": ["api_key"],
|
||||||
|
"note": "请在YAML文件的QUAKE_API_KEY配置项中填写Token,或在环境变量QUAKE_API_KEY中设置。Token可在Quake用户中心获取。"
|
||||||
|
}
|
||||||
|
print(json.dumps(error_result, ensure_ascii=False, indent=2))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not query:
|
||||||
|
error_result = {
|
||||||
|
"status": "error",
|
||||||
|
"message": "缺少必需参数: query(搜索查询语句)",
|
||||||
|
"required_params": ["query"],
|
||||||
|
"examples": [
|
||||||
|
'domain:"example.com"',
|
||||||
|
'ip:"1.1.1.1"',
|
||||||
|
'port:443',
|
||||||
|
'service.name:"http"',
|
||||||
|
'port:22 AND country_cn:"中国"'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
print(json.dumps(error_result, ensure_ascii=False, indent=2))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# 构建请求体
|
||||||
|
data = {
|
||||||
|
"query": query
|
||||||
|
}
|
||||||
|
|
||||||
|
# 可选参数 size(通常最大100)
|
||||||
|
if "size" in config and config["size"] is not None:
|
||||||
|
try:
|
||||||
|
size = int(config["size"])
|
||||||
|
if size > 0:
|
||||||
|
data["size"] = size
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 可选参数 start(分页偏移,默认0)
|
||||||
|
if "start" in config and config["start"] is not None:
|
||||||
|
try:
|
||||||
|
start = int(config["start"])
|
||||||
|
if start >= 0:
|
||||||
|
data["start"] = start
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# fields 映射到 Quake 的 include 字段
|
||||||
|
include_fields = normalize_fields(config.get("fields"))
|
||||||
|
if include_fields:
|
||||||
|
data["include"] = include_fields
|
||||||
|
|
||||||
|
# latest 参数,默认 true(取最新索引结果)
|
||||||
|
latest_value = config.get("latest", True)
|
||||||
|
if isinstance(latest_value, bool):
|
||||||
|
data["latest"] = latest_value
|
||||||
|
elif isinstance(latest_value, str):
|
||||||
|
data["latest"] = latest_value.lower() in ("true", "1", "yes")
|
||||||
|
elif isinstance(latest_value, (int, float)):
|
||||||
|
data["latest"] = latest_value != 0
|
||||||
|
else:
|
||||||
|
data["latest"] = True
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"X-QuakeToken": api_key,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(base_url, json=data, headers=headers, timeout=30)
|
||||||
|
response.raise_for_status()
|
||||||
|
result_data = response.json()
|
||||||
|
|
||||||
|
# Quake API code==0 表示成功
|
||||||
|
if result_data.get("code") != 0:
|
||||||
|
error_result = {
|
||||||
|
"status": "error",
|
||||||
|
"message": f"Quake API错误: {result_data.get('message', '未知错误')}",
|
||||||
|
"error_code": result_data.get("code", "unknown"),
|
||||||
|
"suggestion": "请检查API Token、查询语法和账户积分是否正常"
|
||||||
|
}
|
||||||
|
print(json.dumps(error_result, ensure_ascii=False, indent=2))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
results = result_data.get("data", [])
|
||||||
|
meta = result_data.get("meta", {})
|
||||||
|
pagination = meta.get("pagination", {}) if isinstance(meta, dict) else {}
|
||||||
|
|
||||||
|
output = {
|
||||||
|
"status": "success",
|
||||||
|
"query": query,
|
||||||
|
"size": data.get("size", pagination.get("size", len(results))),
|
||||||
|
"start": data.get("start", pagination.get("page_index", 0)),
|
||||||
|
"total": result_data.get("total_count", pagination.get("total", 0)),
|
||||||
|
"results_count": len(results),
|
||||||
|
"fields": include_fields or "all",
|
||||||
|
"results": results,
|
||||||
|
"message": f"成功获取 {len(results)} 条结果"
|
||||||
|
}
|
||||||
|
|
||||||
|
print(json.dumps(output, ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
error_result = {
|
||||||
|
"status": "error",
|
||||||
|
"message": f"请求失败: {str(e)}",
|
||||||
|
"suggestion": "请检查网络连通性或Quake API服务状态"
|
||||||
|
}
|
||||||
|
print(json.dumps(error_result, ensure_ascii=False, indent=2))
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_result = {
|
||||||
|
"status": "error",
|
||||||
|
"message": f"执行出错: {str(e)}",
|
||||||
|
"type": type(e).__name__
|
||||||
|
}
|
||||||
|
print(json.dumps(error_result, ensure_ascii=False, indent=2))
|
||||||
|
sys.exit(1)
|
||||||
|
enabled: false
|
||||||
|
short_description: "Quake网络空间搜索接口,支持自定义query、size、fields"
|
||||||
|
description: |
|
||||||
|
Quake(360 网络空间测绘)资产搜索工具,调用 Quake API v3 实时检索互联网资产。
|
||||||
|
|
||||||
|
**主要功能:**
|
||||||
|
- 支持 Quake DSL 查询语法(query)
|
||||||
|
- 支持返回数量控制(size)
|
||||||
|
- 支持字段裁剪(fields,对应 Quake include)
|
||||||
|
- 支持分页偏移(start)
|
||||||
|
|
||||||
|
**鉴权方式:**
|
||||||
|
- Header 使用 `X-QuakeToken`
|
||||||
|
- 可在本文件中填写 `QUAKE_API_KEY`,或通过环境变量 `QUAKE_API_KEY` 注入
|
||||||
|
|
||||||
|
**常见查询示例:**
|
||||||
|
- `domain:"example.com"`
|
||||||
|
- `ip:"1.1.1.1"`
|
||||||
|
- `port:443`
|
||||||
|
- `service.name:"http" AND country_cn:"中国"`
|
||||||
|
|
||||||
|
**注意事项:**
|
||||||
|
- API 调用会消耗积分,请按需控制 `size`
|
||||||
|
- `fields` 会映射到请求体 `include` 字段,多个字段用英文逗号分隔
|
||||||
|
- 如遇语法报错,请先在 Quake 控制台验证 DSL
|
||||||
|
parameters:
|
||||||
|
- name: "query"
|
||||||
|
type: "string"
|
||||||
|
description: |
|
||||||
|
Quake DSL 查询语句(必需)。
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
- `domain:"example.com"`
|
||||||
|
- `ip:"1.1.1.1"`
|
||||||
|
- `port:443`
|
||||||
|
- `service.name:"http" AND country_cn:"中国"`
|
||||||
|
required: true
|
||||||
|
position: 1
|
||||||
|
format: "positional"
|
||||||
|
- name: "size"
|
||||||
|
type: "int"
|
||||||
|
description: |
|
||||||
|
返回结果数量(可选)。
|
||||||
|
|
||||||
|
建议范围:1-100(具体受账户权限/接口限制影响)。
|
||||||
|
required: false
|
||||||
|
position: 2
|
||||||
|
format: "positional"
|
||||||
|
default: 10
|
||||||
|
- name: "start"
|
||||||
|
type: "int"
|
||||||
|
description: |
|
||||||
|
分页起始偏移(可选),从 0 开始。
|
||||||
|
required: false
|
||||||
|
position: 3
|
||||||
|
format: "positional"
|
||||||
|
default: 0
|
||||||
|
- name: "fields"
|
||||||
|
type: "string"
|
||||||
|
description: |
|
||||||
|
返回字段(可选),多个字段用英文逗号分隔。
|
||||||
|
|
||||||
|
该参数会映射到 Quake 请求体中的 `include` 字段。
|
||||||
|
**示例:**
|
||||||
|
- `ip,port`
|
||||||
|
- `ip,port,service.name,service.http.title,location.country_cn`
|
||||||
|
required: false
|
||||||
|
position: 4
|
||||||
|
format: "positional"
|
||||||
|
default: "ip,port"
|
||||||
|
- name: "latest"
|
||||||
|
type: "bool"
|
||||||
|
description: |
|
||||||
|
是否优先返回最新索引结果(可选)。
|
||||||
|
默认 `true`。
|
||||||
|
required: false
|
||||||
|
position: 5
|
||||||
|
format: "positional"
|
||||||
|
default: true
|
||||||
@@ -1573,6 +1573,81 @@ header {
|
|||||||
letter-spacing: 0.01em;
|
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 {
|
.message.user .message-bubble .table-wrapper {
|
||||||
scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
|
scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
|
||||||
|
|||||||
@@ -138,6 +138,9 @@
|
|||||||
"expandDetail": "Expand details",
|
"expandDetail": "Expand details",
|
||||||
"noProcessDetail": "No process details (execution may be too fast or no detailed events)",
|
"noProcessDetail": "No process details (execution may be too fast or no detailed events)",
|
||||||
"copyMessageTitle": "Copy message",
|
"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.",
|
"emptyGroupConversations": "This group has no conversations yet.",
|
||||||
"noMatchingConversationsInGroup": "No matching conversations found.",
|
"noMatchingConversationsInGroup": "No matching conversations found.",
|
||||||
"noHistoryConversations": "No conversation history yet",
|
"noHistoryConversations": "No conversation history yet",
|
||||||
@@ -1107,6 +1110,7 @@
|
|||||||
"folderPathCopied": "Folder path copied — paste into chat if needed",
|
"folderPathCopied": "Folder path copied — paste into chat if needed",
|
||||||
"folderEmpty": "This folder is empty",
|
"folderEmpty": "This folder is empty",
|
||||||
"confirmDeleteFolder": "Delete this folder and everything inside it? This cannot be undone.",
|
"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",
|
"deleteFolderTitle": "Delete folder",
|
||||||
"uploadToFolderTitle": "Upload file into this folder",
|
"uploadToFolderTitle": "Upload file into this folder",
|
||||||
"newFolderButton": "New folder",
|
"newFolderButton": "New folder",
|
||||||
@@ -1298,7 +1302,13 @@
|
|||||||
"maxRetriesHint": "Retries on rate limit or server error",
|
"maxRetriesHint": "Retries on rate limit or server error",
|
||||||
"retryDelay": "Retry delay (ms)",
|
"retryDelay": "Retry delay (ms)",
|
||||||
"retryDelayPlaceholder": "1000",
|
"retryDelayPlaceholder": "1000",
|
||||||
"retryDelayHint": "Delay between retries (ms)"
|
"retryDelayHint": "Delay between retries (ms)",
|
||||||
|
"testConnection": "Test Connection",
|
||||||
|
"testFillRequired": "Please fill in API Key and Model first",
|
||||||
|
"testing": "Testing connection...",
|
||||||
|
"testSuccess": "Connection successful",
|
||||||
|
"testFailed": "Connection failed",
|
||||||
|
"testError": "Test error"
|
||||||
},
|
},
|
||||||
"settingsTerminal": {
|
"settingsTerminal": {
|
||||||
"title": "Terminal",
|
"title": "Terminal",
|
||||||
|
|||||||
@@ -138,6 +138,9 @@
|
|||||||
"expandDetail": "展开详情",
|
"expandDetail": "展开详情",
|
||||||
"noProcessDetail": "暂无过程详情(可能执行过快或未触发详细事件)",
|
"noProcessDetail": "暂无过程详情(可能执行过快或未触发详细事件)",
|
||||||
"copyMessageTitle": "复制消息内容",
|
"copyMessageTitle": "复制消息内容",
|
||||||
|
"deleteTurnTitle": "删除本轮对话",
|
||||||
|
"deleteTurnConfirm": "确定删除本轮对话?将同时删除该轮用户消息与助手回复,且无法恢复;下次模型回复将仅基于剩余消息(已保存的上下文快照会清空并按剩余内容重建)。",
|
||||||
|
"deleteTurnFailed": "删除本轮失败",
|
||||||
"emptyGroupConversations": "该分组暂无对话",
|
"emptyGroupConversations": "该分组暂无对话",
|
||||||
"noMatchingConversationsInGroup": "未找到匹配的对话",
|
"noMatchingConversationsInGroup": "未找到匹配的对话",
|
||||||
"noHistoryConversations": "暂无历史对话",
|
"noHistoryConversations": "暂无历史对话",
|
||||||
@@ -1107,6 +1110,7 @@
|
|||||||
"folderPathCopied": "目录路径已复制,可粘贴到对话中",
|
"folderPathCopied": "目录路径已复制,可粘贴到对话中",
|
||||||
"folderEmpty": "此文件夹为空",
|
"folderEmpty": "此文件夹为空",
|
||||||
"confirmDeleteFolder": "确定删除该文件夹及其中的全部文件?此操作不可恢复。",
|
"confirmDeleteFolder": "确定删除该文件夹及其中的全部文件?此操作不可恢复。",
|
||||||
|
"folderRemovedStale": "服务器上已无该目录,列表已刷新。",
|
||||||
"deleteFolderTitle": "删除文件夹",
|
"deleteFolderTitle": "删除文件夹",
|
||||||
"uploadToFolderTitle": "上传文件到此文件夹",
|
"uploadToFolderTitle": "上传文件到此文件夹",
|
||||||
"newFolderButton": "新建文件夹",
|
"newFolderButton": "新建文件夹",
|
||||||
@@ -1298,7 +1302,13 @@
|
|||||||
"maxRetriesHint": "最大重试次数(默认 3),遇到速率限制或服务器错误时自动重试",
|
"maxRetriesHint": "最大重试次数(默认 3),遇到速率限制或服务器错误时自动重试",
|
||||||
"retryDelay": "重试间隔(毫秒)",
|
"retryDelay": "重试间隔(毫秒)",
|
||||||
"retryDelayPlaceholder": "1000",
|
"retryDelayPlaceholder": "1000",
|
||||||
"retryDelayHint": "重试间隔毫秒数(默认 1000),每次重试会递增延迟"
|
"retryDelayHint": "重试间隔毫秒数(默认 1000),每次重试会递增延迟",
|
||||||
|
"testConnection": "测试连接",
|
||||||
|
"testFillRequired": "请先填写 API Key 和模型",
|
||||||
|
"testing": "测试中...",
|
||||||
|
"testSuccess": "连接成功",
|
||||||
|
"testFailed": "连接失败",
|
||||||
|
"testError": "测试出错"
|
||||||
},
|
},
|
||||||
"settingsTerminal": {
|
"settingsTerminal": {
|
||||||
"title": "终端",
|
"title": "终端",
|
||||||
|
|||||||
+58
-98
@@ -1,6 +1,8 @@
|
|||||||
// 对话附件(chat_uploads)文件管理
|
// 对话附件(chat_uploads)文件管理
|
||||||
|
|
||||||
let chatFilesCache = [];
|
let chatFilesCache = [];
|
||||||
|
/** 后端 GET /api/chat-uploads 返回的目录相对路径(含空文件夹),与 files 合并成树 */
|
||||||
|
let chatFilesFoldersCache = [];
|
||||||
let chatFilesDisplayed = [];
|
let chatFilesDisplayed = [];
|
||||||
let chatFilesEditRelativePath = '';
|
let chatFilesEditRelativePath = '';
|
||||||
let chatFilesRenameRelativePath = '';
|
let chatFilesRenameRelativePath = '';
|
||||||
@@ -15,98 +17,6 @@ let chatFilesPendingUploadDir = '';
|
|||||||
/** 文件管理页面向服务器上传进行中,避免重复选择并禁用顶栏按钮 */
|
/** 文件管理页面向服务器上传进行中,避免重复选择并禁用顶栏按钮 */
|
||||||
let chatFilesXHRUploadBusy = false;
|
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() {
|
function chatFilesLoadBrowsePathFromStorage() {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(CHAT_FILES_BROWSE_PATH_KEY);
|
const raw = localStorage.getItem(CHAT_FILES_BROWSE_PATH_KEY);
|
||||||
@@ -157,7 +67,11 @@ function chatFilesNormalizeBrowsePathForTree(root) {
|
|||||||
|
|
||||||
function initChatFilesPage() {
|
function initChatFilesPage() {
|
||||||
chatFilesLoadBrowsePathFromStorage();
|
chatFilesLoadBrowsePathFromStorage();
|
||||||
chatFilesLoadSyntheticDirsFromStorage();
|
try {
|
||||||
|
localStorage.removeItem('csai_chat_files_synthetic_dirs');
|
||||||
|
} catch (e) {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
ensureChatFilesDocClickClose();
|
ensureChatFilesDocClickClose();
|
||||||
const sel = document.getElementById('chat-files-group-by');
|
const sel = document.getElementById('chat-files-group-by');
|
||||||
if (sel) {
|
if (sel) {
|
||||||
@@ -280,6 +194,7 @@ async function loadChatFilesPage() {
|
|||||||
}
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
chatFilesCache = Array.isArray(data.files) ? data.files : [];
|
chatFilesCache = Array.isArray(data.files) ? data.files : [];
|
||||||
|
chatFilesFoldersCache = Array.isArray(data.folders) ? data.folders : [];
|
||||||
renderChatFilesTable();
|
renderChatFilesTable();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@@ -303,7 +218,7 @@ function chatFilesNameFilter(files) {
|
|||||||
|
|
||||||
/** 仅前端按文件名筛选,不重新请求 */
|
/** 仅前端按文件名筛选,不重新请求 */
|
||||||
function chatFilesFilterNameOnInput() {
|
function chatFilesFilterNameOnInput() {
|
||||||
if (!chatFilesCache.length && chatFilesGetGroupByMode() !== 'folder') return;
|
if (!chatFilesCache.length && !chatFilesFoldersCache.length && chatFilesGetGroupByMode() !== 'folder') return;
|
||||||
renderChatFilesTable();
|
renderChatFilesTable();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -463,9 +378,34 @@ function chatFilesBuildTree(files) {
|
|||||||
return root;
|
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() {
|
function chatFilesTreeRootMerged() {
|
||||||
const root = chatFilesBuildTree(chatFilesDisplayed);
|
const root = chatFilesBuildTree(chatFilesDisplayed);
|
||||||
chatFilesMergeSyntheticDirsIntoTree(root);
|
chatFilesMergeFoldersIntoTree(root, chatFilesFoldersCache);
|
||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -907,9 +847,30 @@ async function deleteChatFolderFromBrowse(folderName) {
|
|||||||
body: JSON.stringify({ path: rel })
|
body: JSON.stringify({ path: rel })
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
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();
|
loadChatFilesPage();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert((e && e.message) ? e.message : String(e));
|
alert((e && e.message) ? e.message : String(e));
|
||||||
@@ -1188,7 +1149,6 @@ async function submitChatFilesMkdir() {
|
|||||||
}
|
}
|
||||||
throw new Error(errText || String(res.status));
|
throw new Error(errText || String(res.status));
|
||||||
}
|
}
|
||||||
chatFilesRegisterSyntheticEmptyDir(chatFilesBrowsePath.slice(), name);
|
|
||||||
closeChatFilesMkdirModal();
|
closeChatFilesMkdirModal();
|
||||||
loadChatFilesPage();
|
loadChatFilesPage();
|
||||||
const okMsg = (typeof window.t === 'function')
|
const okMsg = (typeof window.t === 'function')
|
||||||
|
|||||||
+157
-71
@@ -1494,11 +1494,14 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr
|
|||||||
mcpExecutionIds.forEach((execId, index) => {
|
mcpExecutionIds.forEach((execId, index) => {
|
||||||
const detailBtn = document.createElement('button');
|
const detailBtn = document.createElement('button');
|
||||||
detailBtn.className = 'mcp-detail-btn';
|
detailBtn.className = 'mcp-detail-btn';
|
||||||
|
detailBtn.dataset.execId = execId;
|
||||||
|
detailBtn.dataset.execIndex = String(index + 1);
|
||||||
detailBtn.innerHTML = '<span>' + (typeof window.t === 'function' ? window.t('chat.callNumber', { n: index + 1 }) : '调用 #' + (index + 1)) + '</span>';
|
detailBtn.innerHTML = '<span>' + (typeof window.t === 'function' ? window.t('chat.callNumber', { n: index + 1 }) : '调用 #' + (index + 1)) + '</span>';
|
||||||
detailBtn.onclick = () => showMCPDetail(execId);
|
detailBtn.onclick = () => showMCPDetail(execId);
|
||||||
buttonsContainer.appendChild(detailBtn);
|
buttonsContainer.appendChild(detailBtn);
|
||||||
updateButtonWithToolName(detailBtn, execId, index + 1);
|
|
||||||
});
|
});
|
||||||
|
// 使用批量 API 一次性获取所有工具名称(消除 N 次单独请求)
|
||||||
|
batchUpdateButtonToolNames(buttonsContainer, mcpExecutionIds);
|
||||||
|
|
||||||
mcpSection.appendChild(buttonsContainer);
|
mcpSection.appendChild(buttonsContainer);
|
||||||
contentWrapper.appendChild(mcpSection);
|
contentWrapper.appendChild(mcpSection);
|
||||||
@@ -1861,6 +1864,34 @@ async function updateButtonWithToolName(button, executionId, index) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 批量获取工具名称并更新按钮(消除 N 次单独 API 请求,合并为 1 次)
|
||||||
|
async function batchUpdateButtonToolNames(buttonsContainer, executionIds) {
|
||||||
|
if (!executionIds || executionIds.length === 0) return;
|
||||||
|
try {
|
||||||
|
const response = await apiFetch('/api/monitor/executions/names', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ids: executionIds }),
|
||||||
|
});
|
||||||
|
if (!response.ok) return;
|
||||||
|
const nameMap = await response.json(); // { execId: toolName }
|
||||||
|
// 更新对应按钮的文本
|
||||||
|
const buttons = buttonsContainer.querySelectorAll('.mcp-detail-btn[data-exec-id]');
|
||||||
|
buttons.forEach(btn => {
|
||||||
|
const execId = btn.dataset.execId;
|
||||||
|
const index = btn.dataset.execIndex;
|
||||||
|
const toolName = nameMap[execId];
|
||||||
|
if (toolName) {
|
||||||
|
const displayToolName = toolName.includes('::') ? toolName.split('::')[1] : toolName;
|
||||||
|
const span = btn.querySelector('span');
|
||||||
|
if (span) span.textContent = `${displayToolName} #${index}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('批量获取工具名称失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 显示MCP调用详情
|
// 显示MCP调用详情
|
||||||
async function showMCPDetail(executionId) {
|
async function showMCPDetail(executionId) {
|
||||||
try {
|
try {
|
||||||
@@ -2380,15 +2411,14 @@ async function loadConversation(conversationId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 获取当前对话所属的分组ID(用于高亮显示)
|
// 获取当前对话所属的分组ID(用于高亮显示)
|
||||||
// 确保分组映射已加载
|
// 确保分组映射已加载(使用缓存避免重复请求)
|
||||||
if (Object.keys(conversationGroupMappingCache).length === 0) {
|
if (Object.keys(conversationGroupMappingCache).length === 0) {
|
||||||
await loadConversationGroupMapping();
|
await loadConversationGroupMapping();
|
||||||
}
|
}
|
||||||
currentConversationGroupId = conversationGroupMappingCache[conversationId] || null;
|
currentConversationGroupId = conversationGroupMappingCache[conversationId] || null;
|
||||||
|
|
||||||
// 无论是否在分组详情页面,都刷新分组列表,确保高亮状态正确
|
// 异步刷新分组列表高亮状态(不阻塞消息渲染)
|
||||||
// 这样可以清除之前分组的高亮状态,确保UI状态一致
|
loadGroups();
|
||||||
await loadGroups();
|
|
||||||
|
|
||||||
// 更新当前对话ID
|
// 更新当前对话ID
|
||||||
currentConversationId = conversationId;
|
currentConversationId = conversationId;
|
||||||
@@ -2430,13 +2460,15 @@ async function loadConversation(conversationId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载消息
|
// 加载消息 — 分批渲染避免长时间阻塞主线程
|
||||||
if (conversation.messages && conversation.messages.length > 0) {
|
if (conversation.messages && conversation.messages.length > 0) {
|
||||||
conversation.messages.forEach(msg => {
|
const FIRST_BATCH = 20; // 首批同步渲染(用户可见区域)
|
||||||
// 检查消息内容是否为"处理中...",如果是,检查processDetails中是否有错误或取消事件
|
const BATCH_SIZE = 10; // 后续每批条数
|
||||||
|
|
||||||
|
// 渲染单条消息的辅助函数
|
||||||
|
const renderOneMessage = (msg) => {
|
||||||
let displayContent = msg.content;
|
let displayContent = msg.content;
|
||||||
if (msg.role === 'assistant' && msg.content === '处理中...' && msg.processDetails && msg.processDetails.length > 0) {
|
if (msg.role === 'assistant' && msg.content === '处理中...' && msg.processDetails && msg.processDetails.length > 0) {
|
||||||
// 查找最后一个error或cancelled事件
|
|
||||||
for (let i = msg.processDetails.length - 1; i >= 0; i--) {
|
for (let i = msg.processDetails.length - 1; i >= 0; i--) {
|
||||||
const detail = msg.processDetails[i];
|
const detail = msg.processDetails[i];
|
||||||
if (detail.eventType === 'error' || detail.eventType === 'cancelled') {
|
if (detail.eventType === 'error' || detail.eventType === 'cancelled') {
|
||||||
@@ -2446,51 +2478,129 @@ async function loadConversation(conversationId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 传递消息的创建时间
|
|
||||||
const messageId = addMessage(msg.role, displayContent, msg.mcpExecutionIds || [], null, msg.createdAt);
|
const messageId = addMessage(msg.role, displayContent, msg.mcpExecutionIds || [], null, msg.createdAt);
|
||||||
// 绑定后端 messageId,供按需加载过程详情使用
|
|
||||||
const messageEl = document.getElementById(messageId);
|
const messageEl = document.getElementById(messageId);
|
||||||
if (messageEl && msg && msg.id) {
|
if (messageEl && msg && msg.id) {
|
||||||
messageEl.dataset.backendMessageId = String(msg.id);
|
messageEl.dataset.backendMessageId = String(msg.id);
|
||||||
|
attachDeleteTurnButton(messageEl);
|
||||||
}
|
}
|
||||||
// 对于助手消息,总是渲染过程详情(即使没有processDetails也要显示展开详情按钮)
|
|
||||||
if (msg.role === 'assistant') {
|
if (msg.role === 'assistant') {
|
||||||
// 延迟一下,确保消息已经渲染
|
const hasField = msg && Object.prototype.hasOwnProperty.call(msg, 'processDetails');
|
||||||
setTimeout(() => {
|
renderProcessDetails(messageId, hasField ? (msg.processDetails || []) : null);
|
||||||
// 如果后端未返回 processDetails 字段,传 null 表示“尚未加载,点击展开时再请求”
|
if (msg.processDetails && msg.processDetails.length > 0) {
|
||||||
const hasField = msg && Object.prototype.hasOwnProperty.call(msg, 'processDetails');
|
const hasErrorOrCancelled = msg.processDetails.some(d =>
|
||||||
renderProcessDetails(messageId, hasField ? (msg.processDetails || []) : null);
|
d.eventType === 'error' || d.eventType === 'cancelled'
|
||||||
// 如果有过程详情,检查是否有错误或取消事件,如果有,确保详情默认折叠
|
);
|
||||||
if (msg.processDetails && msg.processDetails.length > 0) {
|
if (hasErrorOrCancelled) {
|
||||||
const hasErrorOrCancelled = msg.processDetails.some(d =>
|
collapseAllProgressDetails(messageId, null);
|
||||||
d.eventType === 'error' || d.eventType === 'cancelled'
|
|
||||||
);
|
|
||||||
if (hasErrorOrCancelled) {
|
|
||||||
collapseAllProgressDetails(messageId, null);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, 100);
|
}
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
|
const msgs = conversation.messages;
|
||||||
|
const firstBatch = msgs.slice(0, FIRST_BATCH);
|
||||||
|
const rest = msgs.slice(FIRST_BATCH);
|
||||||
|
|
||||||
|
// 首批同步渲染
|
||||||
|
firstBatch.forEach(renderOneMessage);
|
||||||
|
|
||||||
|
// 剩余消息通过 requestAnimationFrame 分批渲染,避免阻塞 UI
|
||||||
|
if (rest.length > 0) {
|
||||||
|
const savedConvId = conversationId;
|
||||||
|
let offset = 0;
|
||||||
|
const renderNextBatch = () => {
|
||||||
|
// 如果用户已经切换到其他对话,停止渲染
|
||||||
|
if (currentConversationId !== savedConvId) return;
|
||||||
|
const batch = rest.slice(offset, offset + BATCH_SIZE);
|
||||||
|
batch.forEach(renderOneMessage);
|
||||||
|
offset += BATCH_SIZE;
|
||||||
|
if (offset < rest.length) {
|
||||||
|
requestAnimationFrame(renderNextBatch);
|
||||||
|
} else {
|
||||||
|
// 所有消息渲染完毕,滚动到底部
|
||||||
|
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
requestAnimationFrame(renderNextBatch);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const readyMsgEmpty = typeof window.t === 'function' ? window.t('chat.systemReadyMessage') : '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。';
|
const readyMsgEmpty = typeof window.t === 'function' ? window.t('chat.systemReadyMessage') : '系统已就绪。请输入您的测试需求,系统将自动执行相应的安全测试。';
|
||||||
addMessage('assistant', readyMsgEmpty, null, null, null, { systemReadyMessage: true });
|
addMessage('assistant', readyMsgEmpty, null, null, null, { systemReadyMessage: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 滚动到底部
|
// 滚动到底部(首批渲染后立即滚动,剩余批次渲染后会再次滚动)
|
||||||
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
messagesDiv.scrollTop = messagesDiv.scrollHeight;
|
||||||
|
|
||||||
// 添加攻击链按钮
|
// 添加攻击链按钮
|
||||||
addAttackChainButton(conversationId);
|
addAttackChainButton(conversationId);
|
||||||
|
|
||||||
// 刷新对话列表
|
|
||||||
loadConversations();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载对话失败:', error);
|
console.error('加载对话失败:', error);
|
||||||
alert('加载对话失败: ' + error.message);
|
alert('加载对话失败: ' + error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 「删除本轮」:与时间戳同一行(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) {
|
async function deleteConversation(conversationId, skipConfirm = false) {
|
||||||
// 确认删除(如果调用者没有跳过确认)
|
// 确认删除(如果调用者没有跳过确认)
|
||||||
@@ -4359,20 +4469,17 @@ async function loadGroups() {
|
|||||||
async function loadConversationsWithGroups(searchQuery = '') {
|
async function loadConversationsWithGroups(searchQuery = '') {
|
||||||
const loadSeq = ++conversationsListLoadSeq;
|
const loadSeq = ++conversationsListLoadSeq;
|
||||||
try {
|
try {
|
||||||
// 总是重新加载分组列表和分组映射,确保缓存是最新的
|
// 并行加载分组列表、分组映射和对话列表(消除串行等待)
|
||||||
// 这样可以正确处理分组被删除后的情况
|
const limit = (searchQuery && searchQuery.trim()) ? 100 : 100;
|
||||||
await loadGroups();
|
|
||||||
if (loadSeq !== conversationsListLoadSeq) return;
|
|
||||||
await loadConversationGroupMapping();
|
|
||||||
if (loadSeq !== conversationsListLoadSeq) return;
|
|
||||||
|
|
||||||
// 如果有搜索关键词,使用更大的limit以获取所有匹配结果
|
|
||||||
const limit = (searchQuery && searchQuery.trim()) ? 1000 : 100;
|
|
||||||
let url = `/api/conversations?limit=${limit}`;
|
let url = `/api/conversations?limit=${limit}`;
|
||||||
if (searchQuery && searchQuery.trim()) {
|
if (searchQuery && searchQuery.trim()) {
|
||||||
url += '&search=' + encodeURIComponent(searchQuery.trim());
|
url += '&search=' + encodeURIComponent(searchQuery.trim());
|
||||||
}
|
}
|
||||||
const response = await apiFetch(url);
|
const [,, response] = await Promise.all([
|
||||||
|
loadGroups(),
|
||||||
|
loadConversationGroupMapping(),
|
||||||
|
apiFetch(url),
|
||||||
|
]);
|
||||||
if (loadSeq !== conversationsListLoadSeq) return;
|
if (loadSeq !== conversationsListLoadSeq) return;
|
||||||
|
|
||||||
const listContainer = document.getElementById('conversations-list');
|
const listContainer = document.getElementById('conversations-list');
|
||||||
@@ -5370,43 +5477,22 @@ async function removeConversationFromGroup(convId, groupId) {
|
|||||||
// 加载对话分组映射
|
// 加载对话分组映射
|
||||||
async function loadConversationGroupMapping() {
|
async function loadConversationGroupMapping() {
|
||||||
try {
|
try {
|
||||||
// 获取所有分组,然后获取每个分组的对话
|
// 使用批量 API 一次性获取所有映射(消除 N+1 串行请求)
|
||||||
let groups;
|
const response = await apiFetch('/api/groups/mappings');
|
||||||
if (Array.isArray(groupsCache) && groupsCache.length > 0) {
|
|
||||||
groups = groupsCache;
|
|
||||||
} else {
|
|
||||||
const response = await apiFetch('/api/groups');
|
|
||||||
if (!response.ok) {
|
|
||||||
// 如果API请求失败,使用空数组,不打印警告(这是正常错误处理)
|
|
||||||
groups = [];
|
|
||||||
} else {
|
|
||||||
groups = await response.json();
|
|
||||||
// 确保groups是有效数组,只在真正异常时才打印警告
|
|
||||||
if (!Array.isArray(groups)) {
|
|
||||||
// 只在返回的不是数组且不是null/undefined时才打印警告(可能是后端返回了错误格式)
|
|
||||||
if (groups !== null && groups !== undefined) {
|
|
||||||
console.warn('loadConversationGroupMapping: groups不是有效数组,使用空数组', groups);
|
|
||||||
}
|
|
||||||
groups = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存待保留的映射
|
// 保存待保留的映射
|
||||||
const preservedMappings = { ...pendingGroupMappings };
|
const preservedMappings = { ...pendingGroupMappings };
|
||||||
|
|
||||||
conversationGroupMappingCache = {};
|
conversationGroupMappingCache = {};
|
||||||
|
|
||||||
for (const group of groups) {
|
if (response.ok) {
|
||||||
const response = await apiFetch(`/api/groups/${group.id}/conversations`);
|
const mappings = await response.json();
|
||||||
const conversations = await response.json();
|
if (Array.isArray(mappings)) {
|
||||||
// 确保conversations是有效数组
|
mappings.forEach(m => {
|
||||||
if (Array.isArray(conversations)) {
|
conversationGroupMappingCache[m.conversationId] = m.groupId;
|
||||||
conversations.forEach(conv => {
|
|
||||||
conversationGroupMappingCache[conv.id] = group.id;
|
|
||||||
// 如果这个对话在待保留映射中,从待保留映射中移除(因为已经从后端加载了)
|
// 如果这个对话在待保留映射中,从待保留映射中移除(因为已经从后端加载了)
|
||||||
if (preservedMappings[conv.id] === group.id) {
|
if (preservedMappings[m.conversationId] === m.groupId) {
|
||||||
delete pendingGroupMappings[conv.id];
|
delete pendingGroupMappings[m.conversationId];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,17 @@ if (typeof window !== 'undefined') {
|
|||||||
// 存储工具调用ID到DOM元素的映射,用于更新执行状态
|
// 存储工具调用ID到DOM元素的映射,用于更新执行状态
|
||||||
const toolCallStatusMap = new Map();
|
const toolCallStatusMap = new Map();
|
||||||
|
|
||||||
|
function finalizeOutstandingToolCallsForProgress(progressId, finalStatus) {
|
||||||
|
if (!progressId) return;
|
||||||
|
const pid = String(progressId);
|
||||||
|
for (const [toolCallId, mapping] of Array.from(toolCallStatusMap.entries())) {
|
||||||
|
if (!mapping) continue;
|
||||||
|
if (mapping.progressId != null && String(mapping.progressId) !== pid) continue;
|
||||||
|
updateToolCallStatus(toolCallId, finalStatus);
|
||||||
|
toolCallStatusMap.delete(toolCallId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 模型流式输出缓存:progressId -> { assistantId, buffer }
|
// 模型流式输出缓存:progressId -> { assistantId, buffer }
|
||||||
const responseStreamStateByProgressId = new Map();
|
const responseStreamStateByProgressId = new Map();
|
||||||
|
|
||||||
@@ -388,6 +399,11 @@ function integrateProgressToMCPSection(progressId, assistantMessageId, mcpExecut
|
|||||||
const progressElement = document.getElementById(progressId);
|
const progressElement = document.getElementById(progressId);
|
||||||
if (!progressElement) return;
|
if (!progressElement) return;
|
||||||
|
|
||||||
|
// Ensure any "running" tool_call badges are closed before we snapshot timeline HTML.
|
||||||
|
// Otherwise, once the progress element is removed, later 'done' events may not be able
|
||||||
|
// to update the original timeline DOM and the copied HTML would stay "执行中".
|
||||||
|
finalizeOutstandingToolCallsForProgress(progressId, 'failed');
|
||||||
|
|
||||||
const mcpIds = Array.isArray(mcpExecutionIds) ? mcpExecutionIds : [];
|
const mcpIds = Array.isArray(mcpExecutionIds) ? mcpExecutionIds : [];
|
||||||
|
|
||||||
// 获取时间线内容
|
// 获取时间线内容
|
||||||
@@ -444,13 +460,16 @@ function integrateProgressToMCPSection(progressId, assistantMessageId, mcpExecut
|
|||||||
mcpIds.forEach((execId, index) => {
|
mcpIds.forEach((execId, index) => {
|
||||||
const detailBtn = document.createElement('button');
|
const detailBtn = document.createElement('button');
|
||||||
detailBtn.className = 'mcp-detail-btn';
|
detailBtn.className = 'mcp-detail-btn';
|
||||||
|
detailBtn.dataset.execId = execId;
|
||||||
|
detailBtn.dataset.execIndex = String(index + 1);
|
||||||
detailBtn.innerHTML = '<span>' + (typeof window.t === 'function' ? window.t('chat.callNumber', { n: index + 1 }) : '调用 #' + (index + 1)) + '</span>';
|
detailBtn.innerHTML = '<span>' + (typeof window.t === 'function' ? window.t('chat.callNumber', { n: index + 1 }) : '调用 #' + (index + 1)) + '</span>';
|
||||||
detailBtn.onclick = () => showMCPDetail(execId);
|
detailBtn.onclick = () => showMCPDetail(execId);
|
||||||
buttonsContainer.appendChild(detailBtn);
|
buttonsContainer.appendChild(detailBtn);
|
||||||
if (typeof updateButtonWithToolName === 'function') {
|
|
||||||
updateButtonWithToolName(detailBtn, execId, index + 1);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
// 使用批量 API 一次性获取所有工具名称(消除 N 次单独请求)
|
||||||
|
if (typeof batchUpdateButtonToolNames === 'function') {
|
||||||
|
batchUpdateButtonToolNames(buttonsContainer, mcpIds);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!buttonsContainer.querySelector('.process-detail-btn')) {
|
if (!buttonsContainer.querySelector('.process-detail-btn')) {
|
||||||
const progressDetailBtn = document.createElement('button');
|
const progressDetailBtn = document.createElement('button');
|
||||||
@@ -700,10 +719,45 @@ function convertProgressToDetails(progressId, assistantMessageId) {
|
|||||||
scrollChatMessagesToBottomIfPinned(insertWasPinned);
|
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,
|
function handleStreamEvent(event, progressElement, progressId,
|
||||||
getAssistantId, setAssistantId, getMcpIds, setMcpIds) {
|
getAssistantId, setAssistantId, getMcpIds, setMcpIds) {
|
||||||
const streamScrollWasPinned = isChatMessagesPinnedToBottom();
|
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');
|
const timeline = document.getElementById(progressId + '-timeline');
|
||||||
if (!timeline) return;
|
if (!timeline) return;
|
||||||
|
|
||||||
@@ -902,6 +956,9 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
message: event.message || '',
|
message: event.message || '',
|
||||||
data: event.data
|
data: event.data
|
||||||
});
|
});
|
||||||
|
// If the backend triggers a recovery run, any "running" tool_call items in this progress
|
||||||
|
// should be closed to avoid being stuck forever.
|
||||||
|
finalizeOutstandingToolCallsForProgress(progressId, 'failed');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -923,7 +980,8 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
if (toolCallId && toolCallItemId) {
|
if (toolCallId && toolCallItemId) {
|
||||||
toolCallStatusMap.set(toolCallId, {
|
toolCallStatusMap.set(toolCallId, {
|
||||||
itemId: toolCallItemId,
|
itemId: toolCallItemId,
|
||||||
timeline: timeline
|
timeline: timeline,
|
||||||
|
progressId: progressId
|
||||||
});
|
});
|
||||||
|
|
||||||
// 添加执行中状态指示器
|
// 添加执行中状态指示器
|
||||||
@@ -1173,6 +1231,9 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
{
|
{
|
||||||
const preferredMessageId = event.data && event.data.messageId ? event.data.messageId : null;
|
const preferredMessageId = event.data && event.data.messageId ? event.data.messageId : null;
|
||||||
const { assistantId, assistantElement } = upsertTerminalAssistantMessage(event.message, preferredMessageId);
|
const { assistantId, assistantElement } = upsertTerminalAssistantMessage(event.message, preferredMessageId);
|
||||||
|
if (assistantId && preferredMessageId) {
|
||||||
|
applyBackendMessageIdToAssistantDom(assistantId, preferredMessageId);
|
||||||
|
}
|
||||||
if (assistantElement) {
|
if (assistantElement) {
|
||||||
const detailsId = 'process-details-' + assistantId;
|
const detailsId = 'process-details-' + assistantId;
|
||||||
if (!document.getElementById(detailsId)) {
|
if (!document.getElementById(detailsId)) {
|
||||||
@@ -1186,6 +1247,8 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
|
|
||||||
// 立即刷新任务状态
|
// 立即刷新任务状态
|
||||||
loadActiveTasks();
|
loadActiveTasks();
|
||||||
|
// Close any remaining running tool calls for this progress.
|
||||||
|
finalizeOutstandingToolCallsForProgress(progressId, 'failed');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'response_start': {
|
case 'response_start': {
|
||||||
@@ -1299,13 +1362,32 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
updateAssistantBubbleContent(assistantIdFinal, event.message, true);
|
updateAssistantBubbleContent(assistantIdFinal, event.message, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 移除 response_start/response_delta 阶段创建的「规划中」占位条目。
|
||||||
|
// 该条目属于 UI-only 的流式展示,不应被拷贝到最终的过程详情里;
|
||||||
|
// 否则会出现“不刷新页面仍显示规划中,刷新后消失”的不一致。
|
||||||
|
if (streamState && streamState.itemId) {
|
||||||
|
const planningItem = document.getElementById(streamState.itemId);
|
||||||
|
if (planningItem && planningItem.parentNode) {
|
||||||
|
planningItem.parentNode.removeChild(planningItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 最终回复时隐藏进度卡片(多代理模式下,迭代过程已完整展示)
|
// 最终回复时隐藏进度卡片(多代理模式下,迭代过程已完整展示)
|
||||||
hideProgressMessageForFinalReply(progressId);
|
hideProgressMessageForFinalReply(progressId);
|
||||||
|
|
||||||
|
// Before integrating/removing the progress DOM, close any outstanding running tool calls
|
||||||
|
// so the copied timeline HTML reflects the final status.
|
||||||
|
finalizeOutstandingToolCallsForProgress(progressId, 'failed');
|
||||||
|
|
||||||
// 将进度详情集成到工具调用区域(放在最终 response 之后,保证时间线已完整)
|
// 将进度详情集成到工具调用区域(放在最终 response 之后,保证时间线已完整)
|
||||||
integrateProgressToMCPSection(progressId, assistantIdFinal, mcpIds);
|
integrateProgressToMCPSection(progressId, assistantIdFinal, mcpIds);
|
||||||
responseStreamStateByProgressId.delete(progressId);
|
responseStreamStateByProgressId.delete(progressId);
|
||||||
|
|
||||||
|
const respMid = responseData.messageId;
|
||||||
|
if (respMid) {
|
||||||
|
applyBackendMessageIdToAssistantDom(assistantIdFinal, respMid);
|
||||||
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
collapseAllProgressDetails(assistantIdFinal, progressId);
|
collapseAllProgressDetails(assistantIdFinal, progressId);
|
||||||
}, 3000);
|
}, 3000);
|
||||||
@@ -1344,6 +1426,9 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
{
|
{
|
||||||
const preferredMessageId = event.data && event.data.messageId ? event.data.messageId : null;
|
const preferredMessageId = event.data && event.data.messageId ? event.data.messageId : null;
|
||||||
const { assistantId, assistantElement } = upsertTerminalAssistantMessage(event.message, preferredMessageId);
|
const { assistantId, assistantElement } = upsertTerminalAssistantMessage(event.message, preferredMessageId);
|
||||||
|
if (assistantId && preferredMessageId) {
|
||||||
|
applyBackendMessageIdToAssistantDom(assistantId, preferredMessageId);
|
||||||
|
}
|
||||||
if (assistantElement) {
|
if (assistantElement) {
|
||||||
const detailsId = 'process-details-' + assistantId;
|
const detailsId = 'process-details-' + assistantId;
|
||||||
if (!document.getElementById(detailsId)) {
|
if (!document.getElementById(detailsId)) {
|
||||||
@@ -1357,6 +1442,8 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
|
|
||||||
// 立即刷新任务状态(执行失败时任务状态会更新)
|
// 立即刷新任务状态(执行失败时任务状态会更新)
|
||||||
loadActiveTasks();
|
loadActiveTasks();
|
||||||
|
// Close any remaining running tool calls for this progress.
|
||||||
|
finalizeOutstandingToolCallsForProgress(progressId, 'failed');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'done':
|
case 'done':
|
||||||
@@ -1392,6 +1479,8 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
|
|
||||||
// 立即刷新任务状态(确保任务状态同步)
|
// 立即刷新任务状态(确保任务状态同步)
|
||||||
loadActiveTasks();
|
loadActiveTasks();
|
||||||
|
// Close any remaining running tool calls for this progress (best-effort).
|
||||||
|
finalizeOutstandingToolCallsForProgress(progressId, 'failed');
|
||||||
|
|
||||||
// 延迟再次刷新任务状态(确保后端已完成状态更新)
|
// 延迟再次刷新任务状态(确保后端已完成状态更新)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
@@ -959,6 +959,57 @@ async function applySettings() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 测试OpenAI连接
|
||||||
|
async function testOpenAIConnection() {
|
||||||
|
const btn = document.getElementById('test-openai-btn');
|
||||||
|
const resultEl = document.getElementById('test-openai-result');
|
||||||
|
|
||||||
|
const baseUrl = document.getElementById('openai-base-url').value.trim();
|
||||||
|
const apiKey = document.getElementById('openai-api-key').value.trim();
|
||||||
|
const model = document.getElementById('openai-model').value.trim();
|
||||||
|
|
||||||
|
if (!apiKey || !model) {
|
||||||
|
resultEl.style.color = 'var(--danger-color, #e53e3e)';
|
||||||
|
resultEl.textContent = typeof window.t === 'function' ? window.t('settingsBasic.testFillRequired') : '请先填写 API Key 和模型';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.style.pointerEvents = 'none';
|
||||||
|
btn.style.opacity = '0.5';
|
||||||
|
resultEl.style.color = 'var(--text-muted, #888)';
|
||||||
|
resultEl.textContent = typeof window.t === 'function' ? window.t('settingsBasic.testing') : '测试中...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await apiFetch('/api/config/test-openai', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
base_url: baseUrl,
|
||||||
|
api_key: apiKey,
|
||||||
|
model: model
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
resultEl.style.color = 'var(--success-color, #38a169)';
|
||||||
|
const latency = result.latency_ms ? ` (${result.latency_ms}ms)` : '';
|
||||||
|
const modelInfo = result.model ? ` [${result.model}]` : '';
|
||||||
|
resultEl.textContent = (typeof window.t === 'function' ? window.t('settingsBasic.testSuccess') : '连接成功') + modelInfo + latency;
|
||||||
|
} else {
|
||||||
|
resultEl.style.color = 'var(--danger-color, #e53e3e)';
|
||||||
|
resultEl.textContent = (typeof window.t === 'function' ? window.t('settingsBasic.testFailed') : '连接失败') + ': ' + (result.error || '未知错误');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
resultEl.style.color = 'var(--danger-color, #e53e3e)';
|
||||||
|
resultEl.textContent = (typeof window.t === 'function' ? window.t('settingsBasic.testError') : '测试出错') + ': ' + error.message;
|
||||||
|
} finally {
|
||||||
|
btn.style.pointerEvents = '';
|
||||||
|
btn.style.opacity = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 保存工具配置(独立函数,用于MCP管理页面)
|
// 保存工具配置(独立函数,用于MCP管理页面)
|
||||||
async function saveToolsConfig() {
|
async function saveToolsConfig() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1371,6 +1371,10 @@
|
|||||||
<label for="openai-model"><span data-i18n="settingsBasic.model">模型</span> <span style="color: red;">*</span></label>
|
<label for="openai-model"><span data-i18n="settingsBasic.model">模型</span> <span style="color: red;">*</span></label>
|
||||||
<input type="text" id="openai-model" data-i18n="settingsBasic.modelPlaceholder" data-i18n-attr="placeholder" placeholder="gpt-4" required />
|
<input type="text" id="openai-model" data-i18n="settingsBasic.modelPlaceholder" data-i18n-attr="placeholder" placeholder="gpt-4" required />
|
||||||
</div>
|
</div>
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px; margin-top: 2px;">
|
||||||
|
<a href="javascript:void(0)" id="test-openai-btn" onclick="testOpenAIConnection()" style="font-size: 0.8125rem; color: var(--accent-color, #3182ce); text-decoration: none; cursor: pointer; user-select: none;" data-i18n="settingsBasic.testConnection">测试连接</a>
|
||||||
|
<span id="test-openai-result" style="font-size: 0.8125rem;"></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user