mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-05-18 05:54:47 +02:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d87bc09a2e | |||
| 6cd89414f9 | |||
| e538a744c3 | |||
| dd4d534e24 | |||
| f1a31a459c | |||
| 4fd083ff37 | |||
| acef729800 | |||
| e7609c5fc4 | |||
| 2b6d0486c8 | |||
| d5eb4ce119 | |||
| 92a8339267 | |||
| f196992b91 | |||
| f64b7653ac | |||
| 2a9b18ba7b | |||
| 6f70d7b851 | |||
| 157f1c9754 |
@@ -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.6"
|
version: "v1.4.9"
|
||||||
# 服务器配置
|
# 服务器配置
|
||||||
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 |
@@ -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)
|
||||||
|
|
||||||
// 对话分组
|
// 对话分组
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -457,6 +458,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 +554,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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -92,6 +92,19 @@ func (m *mcpBridgeTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
|
|||||||
|
|
||||||
func (m *mcpBridgeTool) InvokableRun(ctx context.Context, argumentsInJSON string, opts ...tool.Option) (string, error) {
|
func (m *mcpBridgeTool) InvokableRun(ctx context.Context, argumentsInJSON string, opts ...tool.Option) (string, error) {
|
||||||
_ = opts
|
_ = opts
|
||||||
|
return runMCPToolInvocation(ctx, m.agent, m.holder, m.name, argumentsInJSON, m.record, m.chunk)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runMCPToolInvocation 与 mcpBridgeTool.InvokableRun 共用。
|
||||||
|
func runMCPToolInvocation(
|
||||||
|
ctx context.Context,
|
||||||
|
ag *agent.Agent,
|
||||||
|
holder *ConversationHolder,
|
||||||
|
toolName string,
|
||||||
|
argumentsInJSON string,
|
||||||
|
record ExecutionRecorder,
|
||||||
|
chunk func(toolName, toolCallID, chunk string),
|
||||||
|
) (string, error) {
|
||||||
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 {
|
||||||
@@ -102,44 +115,62 @@ func (m *mcpBridgeTool) InvokableRun(ctx context.Context, argumentsInJSON string
|
|||||||
args = map[string]interface{}{}
|
args = map[string]interface{}{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stream tool output (stdout/stderr) to upper layer via security.Executor's callback.
|
if chunk != nil {
|
||||||
// This enables multi-agent mode to show execution progress on the frontend.
|
|
||||||
if m.chunk != nil {
|
|
||||||
toolCallID := compose.GetToolCallID(ctx)
|
toolCallID := compose.GetToolCallID(ctx)
|
||||||
if toolCallID != "" {
|
if toolCallID != "" {
|
||||||
if existing, ok := ctx.Value(security.ToolOutputCallbackCtxKey).(security.ToolOutputCallback); ok && existing != nil {
|
if existing, ok := ctx.Value(security.ToolOutputCallbackCtxKey).(security.ToolOutputCallback); ok && existing != nil {
|
||||||
// Chain existing callback (if any) + our progress forwarder.
|
|
||||||
ctx = context.WithValue(ctx, security.ToolOutputCallbackCtxKey, security.ToolOutputCallback(func(c string) {
|
ctx = context.WithValue(ctx, security.ToolOutputCallbackCtxKey, security.ToolOutputCallback(func(c string) {
|
||||||
existing(c)
|
existing(c)
|
||||||
if strings.TrimSpace(c) == "" {
|
if strings.TrimSpace(c) == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
m.chunk(m.name, toolCallID, c)
|
chunk(toolName, toolCallID, c)
|
||||||
}))
|
}))
|
||||||
} else {
|
} else {
|
||||||
ctx = context.WithValue(ctx, security.ToolOutputCallbackCtxKey, security.ToolOutputCallback(func(c string) {
|
ctx = context.WithValue(ctx, security.ToolOutputCallbackCtxKey, security.ToolOutputCallback(func(c string) {
|
||||||
if strings.TrimSpace(c) == "" {
|
if strings.TrimSpace(c) == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
m.chunk(m.name, toolCallID, c)
|
chunk(toolName, toolCallID, c)
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
conv := m.holder.Get()
|
res, err := ag.ExecuteMCPToolForConversation(ctx, holder.Get(), toolName, args)
|
||||||
res, err := m.agent.ExecuteMCPToolForConversation(ctx, conv, m.name, args)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if res == nil {
|
if res == nil {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
if res.ExecutionID != "" && m.record != nil {
|
if res.ExecutionID != "" && record != nil {
|
||||||
m.record(res.ExecutionID)
|
record(res.ExecutionID)
|
||||||
}
|
}
|
||||||
if res.IsError {
|
if res.IsError {
|
||||||
return ToolErrorPrefix + res.Result, nil
|
return ToolErrorPrefix + res.Result, nil
|
||||||
}
|
}
|
||||||
return res.Result, nil
|
return res.Result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UnknownToolReminderHandler 供 compose.ToolsNodeConfig.UnknownToolsHandler 使用:
|
||||||
|
// 模型请求了未注册的工具名时,仅返回说明性文本,error 恒为 nil,以便 ReAct 继续迭代而不中断图执行。
|
||||||
|
// 不进行名称猜测或映射,避免误执行。
|
||||||
|
func UnknownToolReminderHandler() func(ctx context.Context, name, input string) (string, error) {
|
||||||
|
return func(ctx context.Context, name, input string) (string, error) {
|
||||||
|
_ = ctx
|
||||||
|
_ = input
|
||||||
|
return unknownToolReminderText(strings.TrimSpace(name)), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func unknownToolReminderText(requested string) string {
|
||||||
|
if requested == "" {
|
||||||
|
requested = "(empty)"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(`The tool name %q is not registered for this agent.
|
||||||
|
|
||||||
|
Please retry using only names that appear in the tool definitions for this turn (exact match, case-sensitive). Do not invent or rename tools; adjust your plan and continue.
|
||||||
|
|
||||||
|
(工具 %q 未注册:请仅使用本回合上下文中给出的工具名称,须完全一致;请勿自行改写或猜测名称,并继续后续步骤。)`, requested, requested)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package einomcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUnknownToolReminderText(t *testing.T) {
|
||||||
|
s := unknownToolReminderText("bad_tool")
|
||||||
|
if !strings.Contains(s, "bad_tool") {
|
||||||
|
t.Fatalf("expected requested name in message: %s", s)
|
||||||
|
}
|
||||||
|
if strings.Contains(s, "Tools currently available") {
|
||||||
|
t.Fatal("unified message must not list tool names")
|
||||||
|
}
|
||||||
|
}
|
||||||
+156
-9
@@ -79,8 +79,8 @@ type AgentHandler struct {
|
|||||||
knowledgeManager interface { // 知识库管理器接口
|
knowledgeManager interface { // 知识库管理器接口
|
||||||
LogRetrieval(conversationID, messageID, query, riskType string, retrievedItems []string) error
|
LogRetrieval(conversationID, messageID, query, riskType string, retrievedItems []string) error
|
||||||
}
|
}
|
||||||
skillsManager *skills.Manager // Skills管理器
|
skillsManager *skills.Manager // Skills管理器
|
||||||
agentsMarkdownDir string // 多代理:Markdown 子 Agent 目录(绝对路径,空则不从磁盘合并)
|
agentsMarkdownDir string // 多代理:Markdown 子 Agent 目录(绝对路径,空则不从磁盘合并)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAgentHandler 创建新的Agent处理器
|
// NewAgentHandler 创建新的Agent处理器
|
||||||
@@ -122,8 +122,8 @@ func (h *AgentHandler) SetAgentsMarkdownDir(absDir string) {
|
|||||||
|
|
||||||
// ChatAttachment 聊天附件(用户上传的文件)
|
// ChatAttachment 聊天附件(用户上传的文件)
|
||||||
type ChatAttachment struct {
|
type ChatAttachment struct {
|
||||||
FileName string `json:"fileName"` // 展示用文件名
|
FileName string `json:"fileName"` // 展示用文件名
|
||||||
Content string `json:"content,omitempty"` // 文本或 base64;若已预先上传到服务器可留空
|
Content string `json:"content,omitempty"` // 文本或 base64;若已预先上传到服务器可留空
|
||||||
MimeType string `json:"mimeType,omitempty"`
|
MimeType string `json:"mimeType,omitempty"`
|
||||||
ServerPath string `json:"serverPath,omitempty"` // 已保存在 chat_uploads 下的绝对路径(由 POST /api/chat-uploads 返回)
|
ServerPath string `json:"serverPath,omitempty"` // 已保存在 chat_uploads 下的绝对路径(由 POST /api/chat-uploads 返回)
|
||||||
}
|
}
|
||||||
@@ -714,6 +714,73 @@ func (h *AgentHandler) createProgressCallback(conversationID, assistantMessageID
|
|||||||
// 用于保存tool_call事件中的参数,以便在tool_result时使用
|
// 用于保存tool_call事件中的参数,以便在tool_result时使用
|
||||||
toolCallCache := make(map[string]map[string]interface{}) // toolCallId -> arguments
|
toolCallCache := make(map[string]map[string]interface{}) // toolCallId -> arguments
|
||||||
|
|
||||||
|
// thinking_stream_*:不逐条落库,按 streamId 聚合,在后续关键事件前补一条可持久化的 thinking
|
||||||
|
type thinkingBuf struct {
|
||||||
|
b strings.Builder
|
||||||
|
meta map[string]interface{}
|
||||||
|
}
|
||||||
|
thinkingStreams := make(map[string]*thinkingBuf) // streamId -> buf
|
||||||
|
flushedThinking := make(map[string]bool) // streamId -> flushed
|
||||||
|
|
||||||
|
// response_start + response_delta:前端时间线显示为「📝 规划中」(monitor.js),不落逐条 delta;
|
||||||
|
// 聚合为一条 planning 写入 process_details,刷新后与线上一致。
|
||||||
|
var respPlan struct {
|
||||||
|
meta map[string]interface{}
|
||||||
|
b strings.Builder
|
||||||
|
}
|
||||||
|
flushResponsePlan := func() {
|
||||||
|
if assistantMessageID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
content := strings.TrimSpace(respPlan.b.String())
|
||||||
|
if content == "" {
|
||||||
|
respPlan.meta = nil
|
||||||
|
respPlan.b.Reset()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"source": "response_stream",
|
||||||
|
}
|
||||||
|
for k, v := range respPlan.meta {
|
||||||
|
data[k] = v
|
||||||
|
}
|
||||||
|
if err := h.db.AddProcessDetail(assistantMessageID, conversationID, "planning", content, data); err != nil {
|
||||||
|
h.logger.Warn("保存过程详情失败", zap.Error(err), zap.String("eventType", "planning"))
|
||||||
|
}
|
||||||
|
respPlan.meta = nil
|
||||||
|
respPlan.b.Reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
flushThinkingStreams := func() {
|
||||||
|
if assistantMessageID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for sid, tb := range thinkingStreams {
|
||||||
|
if sid == "" || flushedThinking[sid] || tb == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
content := strings.TrimSpace(tb.b.String())
|
||||||
|
if content == "" {
|
||||||
|
flushedThinking[sid] = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"streamId": sid,
|
||||||
|
}
|
||||||
|
for k, v := range tb.meta {
|
||||||
|
// 避免覆盖 streamId
|
||||||
|
if k == "streamId" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data[k] = v
|
||||||
|
}
|
||||||
|
if err := h.db.AddProcessDetail(assistantMessageID, conversationID, "thinking", content, data); err != nil {
|
||||||
|
h.logger.Warn("保存过程详情失败", zap.Error(err), zap.String("eventType", "thinking"))
|
||||||
|
}
|
||||||
|
flushedThinking[sid] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return func(eventType, message string, data interface{}) {
|
return func(eventType, message string, data interface{}) {
|
||||||
// 如果提供了sendEventFunc,发送流式事件
|
// 如果提供了sendEventFunc,发送流式事件
|
||||||
if sendEventFunc != nil {
|
if sendEventFunc != nil {
|
||||||
@@ -846,25 +913,97 @@ func (h *AgentHandler) createProgressCallback(conversationID, assistantMessageID
|
|||||||
|
|
||||||
// 子代理回复流式增量不落库;结束时合并为一条 eino_agent_reply
|
// 子代理回复流式增量不落库;结束时合并为一条 eino_agent_reply
|
||||||
if assistantMessageID != "" && eventType == "eino_agent_reply_stream_end" {
|
if assistantMessageID != "" && eventType == "eino_agent_reply_stream_end" {
|
||||||
|
flushResponsePlan()
|
||||||
|
// 确保思考流在子代理回复前能持久化(刷新后可读)
|
||||||
|
flushThinkingStreams()
|
||||||
if err := h.db.AddProcessDetail(assistantMessageID, conversationID, "eino_agent_reply", message, data); err != nil {
|
if err := h.db.AddProcessDetail(assistantMessageID, conversationID, "eino_agent_reply", message, data); err != nil {
|
||||||
h.logger.Warn("保存过程详情失败", zap.Error(err), zap.String("eventType", eventType))
|
h.logger.Warn("保存过程详情失败", zap.Error(err), zap.String("eventType", eventType))
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存过程详情到数据库(排除response/done事件,它们会在后面单独处理)
|
// 多代理主代理「规划中」:response_start / response_delta 仅用于 SSE,聚合落一条 planning
|
||||||
// 另外:response_start/response_delta 是模型流式增量,保存会导致过程详情膨胀,因此不落库。
|
if eventType == "response_start" {
|
||||||
|
flushResponsePlan()
|
||||||
|
respPlan.meta = nil
|
||||||
|
if dataMap, ok := data.(map[string]interface{}); ok {
|
||||||
|
respPlan.meta = make(map[string]interface{}, len(dataMap))
|
||||||
|
for k, v := range dataMap {
|
||||||
|
respPlan.meta[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
respPlan.b.Reset()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if eventType == "response_delta" {
|
||||||
|
respPlan.b.WriteString(message)
|
||||||
|
if dataMap, ok := data.(map[string]interface{}); ok && respPlan.meta == nil {
|
||||||
|
respPlan.meta = make(map[string]interface{}, len(dataMap))
|
||||||
|
for k, v := range dataMap {
|
||||||
|
respPlan.meta[k] = v
|
||||||
|
}
|
||||||
|
} else if dataMap, ok := data.(map[string]interface{}); ok {
|
||||||
|
for k, v := range dataMap {
|
||||||
|
respPlan.meta[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if eventType == "response" {
|
||||||
|
flushResponsePlan()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 聚合 thinking_stream_*(ReasoningContent),不逐条落库
|
||||||
|
if eventType == "thinking_stream_start" {
|
||||||
|
if dataMap, ok := data.(map[string]interface{}); ok {
|
||||||
|
if sid, ok2 := dataMap["streamId"].(string); ok2 && sid != "" {
|
||||||
|
tb := thinkingStreams[sid]
|
||||||
|
if tb == nil {
|
||||||
|
tb = &thinkingBuf{meta: map[string]interface{}{}}
|
||||||
|
thinkingStreams[sid] = tb
|
||||||
|
}
|
||||||
|
// 记录元信息(source/einoAgent/einoRole/iteration 等)
|
||||||
|
for k, v := range dataMap {
|
||||||
|
tb.meta[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if eventType == "thinking_stream_delta" {
|
||||||
|
if dataMap, ok := data.(map[string]interface{}); ok {
|
||||||
|
if sid, ok2 := dataMap["streamId"].(string); ok2 && sid != "" {
|
||||||
|
tb := thinkingStreams[sid]
|
||||||
|
if tb == nil {
|
||||||
|
tb = &thinkingBuf{meta: map[string]interface{}{}}
|
||||||
|
thinkingStreams[sid] = tb
|
||||||
|
}
|
||||||
|
// delta 片段直接拼接;message 本身就是 reasoning content
|
||||||
|
tb.b.WriteString(message)
|
||||||
|
// 有时 delta 先到 start 未到,补充元信息
|
||||||
|
for k, v := range dataMap {
|
||||||
|
tb.meta[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存过程详情到数据库(排除 response/done;response 正文已在 messages 表)
|
||||||
|
// response_start/response_delta 已聚合为 planning,不落逐条。
|
||||||
if assistantMessageID != "" &&
|
if assistantMessageID != "" &&
|
||||||
eventType != "response" &&
|
eventType != "response" &&
|
||||||
eventType != "done" &&
|
eventType != "done" &&
|
||||||
eventType != "response_start" &&
|
eventType != "response_start" &&
|
||||||
eventType != "response_delta" &&
|
eventType != "response_delta" &&
|
||||||
eventType != "tool_result_delta" &&
|
eventType != "tool_result_delta" &&
|
||||||
eventType != "thinking_stream_start" &&
|
|
||||||
eventType != "thinking_stream_delta" &&
|
|
||||||
eventType != "eino_agent_reply_stream_start" &&
|
eventType != "eino_agent_reply_stream_start" &&
|
||||||
eventType != "eino_agent_reply_stream_delta" &&
|
eventType != "eino_agent_reply_stream_delta" &&
|
||||||
eventType != "eino_agent_reply_stream_end" {
|
eventType != "eino_agent_reply_stream_end" {
|
||||||
|
// 在关键过程事件落库前,先把「规划中」与 thinking_stream 落库
|
||||||
|
flushResponsePlan()
|
||||||
|
flushThinkingStreams()
|
||||||
if err := h.db.AddProcessDetail(assistantMessageID, conversationID, eventType, message, data); err != nil {
|
if err := h.db.AddProcessDetail(assistantMessageID, conversationID, eventType, message, data); err != nil {
|
||||||
h.logger.Warn("保存过程详情失败", zap.Error(err), zap.String("eventType", eventType))
|
h.logger.Warn("保存过程详情失败", zap.Error(err), zap.String("eventType", eventType))
|
||||||
}
|
}
|
||||||
@@ -1117,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))
|
||||||
}
|
}
|
||||||
@@ -1136,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=...
|
||||||
|
|||||||
@@ -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",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+271
-229
@@ -101,8 +101,8 @@ func RunDeepAgent(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
progress("tool_result_delta", chunk, map[string]interface{}{
|
progress("tool_result_delta", chunk, map[string]interface{}{
|
||||||
"toolName": toolName,
|
"toolName": toolName,
|
||||||
"toolCallId": toolCallID,
|
"toolCallId": toolCallID,
|
||||||
// index/total/iteration are optional for UI; we don't know them in this bridge.
|
// index/total/iteration are optional for UI; we don't know them in this bridge.
|
||||||
"index": 0,
|
"index": 0,
|
||||||
"total": 0,
|
"total": 0,
|
||||||
@@ -221,7 +221,8 @@ func RunDeepAgent(
|
|||||||
Model: subModel,
|
Model: subModel,
|
||||||
ToolsConfig: adk.ToolsConfig{
|
ToolsConfig: adk.ToolsConfig{
|
||||||
ToolsNodeConfig: compose.ToolsNodeConfig{
|
ToolsNodeConfig: compose.ToolsNodeConfig{
|
||||||
Tools: subTools,
|
Tools: subTools,
|
||||||
|
UnknownToolsHandler: einomcp.UnknownToolReminderHandler(),
|
||||||
},
|
},
|
||||||
EmitInternalEvents: true,
|
EmitInternalEvents: true,
|
||||||
},
|
},
|
||||||
@@ -275,7 +276,8 @@ func RunDeepAgent(
|
|||||||
},
|
},
|
||||||
ToolsConfig: adk.ToolsConfig{
|
ToolsConfig: adk.ToolsConfig{
|
||||||
ToolsNodeConfig: compose.ToolsNodeConfig{
|
ToolsNodeConfig: compose.ToolsNodeConfig{
|
||||||
Tools: mainTools,
|
Tools: mainTools,
|
||||||
|
UnknownToolsHandler: einomcp.UnknownToolReminderHandler(),
|
||||||
},
|
},
|
||||||
EmitInternalEvents: true,
|
EmitInternalEvents: true,
|
||||||
},
|
},
|
||||||
@@ -284,14 +286,8 @@ func RunDeepAgent(
|
|||||||
return nil, fmt.Errorf("deep.New: %w", err)
|
return nil, fmt.Errorf("deep.New: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
msgs := historyToMessages(history)
|
baseMsgs := historyToMessages(history)
|
||||||
msgs = append(msgs, schema.UserMessage(userMessage))
|
baseMsgs = append(baseMsgs, schema.UserMessage(userMessage))
|
||||||
|
|
||||||
runner := adk.NewRunner(ctx, adk.RunnerConfig{
|
|
||||||
Agent: da,
|
|
||||||
EnableStreaming: true,
|
|
||||||
})
|
|
||||||
iter := runner.Run(ctx, msgs)
|
|
||||||
|
|
||||||
streamsMainAssistant := func(agent string) bool {
|
streamsMainAssistant := func(agent string) bool {
|
||||||
return agent == "" || agent == orchestratorName
|
return agent == "" || agent == orchestratorName
|
||||||
@@ -303,255 +299,301 @@ func RunDeepAgent(
|
|||||||
return "sub"
|
return "sub"
|
||||||
}
|
}
|
||||||
|
|
||||||
// 仅保留主代理最后一次 assistant 输出,避免把多轮中间回复拼接到最终答案。
|
var lastRunMsgs []adk.Message
|
||||||
var lastAssistant string
|
var lastAssistant string
|
||||||
var reasoningStreamSeq int64
|
|
||||||
var einoSubReplyStreamSeq int64
|
attemptLoop:
|
||||||
toolEmitSeen := make(map[string]struct{})
|
for attempt := 0; attempt < maxToolCallArgumentsJSONAttempts; attempt++ {
|
||||||
// 主代理「外层轮次」:首次进入编排器为第 1 轮,每从子代理回到编排器 +1。
|
msgs := make([]adk.Message, 0, len(baseMsgs)+attempt)
|
||||||
// 子代理「步数」:该子代理每次发起一批工具调用前 +1(近似 ReAct 步)。
|
msgs = append(msgs, baseMsgs...)
|
||||||
var einoMainRound int
|
for i := 0; i < attempt; i++ {
|
||||||
var einoLastAgent string
|
msgs = append(msgs, toolCallArgumentsJSONRetryHint())
|
||||||
subAgentToolStep := make(map[string]int)
|
|
||||||
for {
|
|
||||||
ev, ok := iter.Next()
|
|
||||||
if !ok {
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
if ev == nil {
|
|
||||||
continue
|
if attempt > 0 {
|
||||||
}
|
mcpIDsMu.Lock()
|
||||||
if ev.Err != nil {
|
mcpIDs = mcpIDs[:0]
|
||||||
|
mcpIDsMu.Unlock()
|
||||||
|
if logger != nil {
|
||||||
|
logger.Warn("eino DeepAgent: 工具参数 JSON 被接口拒绝,追加提示后重试",
|
||||||
|
zap.Int("attempt", attempt),
|
||||||
|
zap.Int("maxAttempts", maxToolCallArgumentsJSONAttempts))
|
||||||
|
}
|
||||||
if progress != nil {
|
if progress != nil {
|
||||||
progress("error", ev.Err.Error(), map[string]interface{}{
|
// 使用专用事件类型 eino_recovery,便于前端时间线展示(progress 仅改标题,不进时间线)
|
||||||
|
progress("eino_recovery", toolCallArgumentsJSONRecoveryTimelineMessage(attempt), map[string]interface{}{
|
||||||
"conversationId": conversationID,
|
"conversationId": conversationID,
|
||||||
"source": "eino",
|
"source": "eino",
|
||||||
|
"einoRetry": attempt,
|
||||||
|
"runIndex": attempt + 1, // 第几轮完整运行(1 为首次,重试后递增)
|
||||||
|
"maxRuns": maxToolCallArgumentsJSONAttempts,
|
||||||
|
"reason": "invalid_tool_arguments_json",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return nil, ev.Err
|
|
||||||
}
|
}
|
||||||
if ev.AgentName != "" && progress != nil {
|
|
||||||
if streamsMainAssistant(ev.AgentName) {
|
|
||||||
if einoMainRound == 0 {
|
|
||||||
einoMainRound = 1
|
|
||||||
progress("iteration", "", map[string]interface{}{
|
|
||||||
"iteration": 1,
|
|
||||||
"einoScope": "main",
|
|
||||||
"einoRole": "orchestrator",
|
|
||||||
"einoAgent": orchestratorName,
|
|
||||||
"conversationId": conversationID,
|
|
||||||
"source": "eino",
|
|
||||||
})
|
|
||||||
} else if einoLastAgent != "" && !streamsMainAssistant(einoLastAgent) {
|
|
||||||
einoMainRound++
|
|
||||||
progress("iteration", "", map[string]interface{}{
|
|
||||||
"iteration": einoMainRound,
|
|
||||||
"einoScope": "main",
|
|
||||||
"einoRole": "orchestrator",
|
|
||||||
"einoAgent": orchestratorName,
|
|
||||||
"conversationId": conversationID,
|
|
||||||
"source": "eino",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
einoLastAgent = ev.AgentName
|
|
||||||
progress("progress", fmt.Sprintf("[Eino] %s", ev.AgentName), map[string]interface{}{
|
|
||||||
"conversationId": conversationID,
|
|
||||||
"einoAgent": ev.AgentName,
|
|
||||||
"einoRole": einoRoleTag(ev.AgentName),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if ev.Output == nil || ev.Output.MessageOutput == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
mv := ev.Output.MessageOutput
|
|
||||||
|
|
||||||
if mv.IsStreaming && mv.MessageStream != nil {
|
// 仅保留主代理最后一次 assistant 输出;每轮重试重置,避免拼接失败轮次的片段。
|
||||||
streamHeaderSent := false
|
lastAssistant = ""
|
||||||
var reasoningStreamID string
|
var reasoningStreamSeq int64
|
||||||
var toolStreamFragments []schema.ToolCall
|
var einoSubReplyStreamSeq int64
|
||||||
var subAssistantBuf strings.Builder
|
toolEmitSeen := make(map[string]struct{})
|
||||||
var subReplyStreamID string
|
var einoMainRound int
|
||||||
var mainAssistantBuf strings.Builder
|
var einoLastAgent string
|
||||||
for {
|
subAgentToolStep := make(map[string]int)
|
||||||
chunk, rerr := mv.MessageStream.Recv()
|
|
||||||
if rerr != nil {
|
runner := adk.NewRunner(ctx, adk.RunnerConfig{
|
||||||
if errors.Is(rerr, io.EOF) {
|
Agent: da,
|
||||||
break
|
EnableStreaming: true,
|
||||||
}
|
})
|
||||||
|
iter := runner.Run(ctx, msgs)
|
||||||
|
|
||||||
|
for {
|
||||||
|
ev, ok := iter.Next()
|
||||||
|
if !ok {
|
||||||
|
lastRunMsgs = msgs
|
||||||
|
break attemptLoop
|
||||||
|
}
|
||||||
|
if ev == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ev.Err != nil {
|
||||||
|
if isRecoverableToolCallArgumentsJSONError(ev.Err) && attempt+1 < maxToolCallArgumentsJSONAttempts {
|
||||||
if logger != nil {
|
if logger != nil {
|
||||||
logger.Warn("eino stream recv", zap.Error(rerr))
|
logger.Warn("eino: recoverable tool-call JSON error from model/API", zap.Error(ev.Err), zap.Int("attempt", attempt))
|
||||||
}
|
}
|
||||||
break
|
continue attemptLoop
|
||||||
}
|
}
|
||||||
if chunk == nil {
|
if progress != nil {
|
||||||
continue
|
progress("error", ev.Err.Error(), map[string]interface{}{
|
||||||
}
|
"conversationId": conversationID,
|
||||||
if progress != nil && strings.TrimSpace(chunk.ReasoningContent) != "" {
|
"source": "eino",
|
||||||
if reasoningStreamID == "" {
|
|
||||||
reasoningStreamID = fmt.Sprintf("eino-reasoning-%s-%d", conversationID, atomic.AddInt64(&reasoningStreamSeq, 1))
|
|
||||||
progress("thinking_stream_start", " ", map[string]interface{}{
|
|
||||||
"streamId": reasoningStreamID,
|
|
||||||
"source": "eino",
|
|
||||||
"einoAgent": ev.AgentName,
|
|
||||||
"einoRole": einoRoleTag(ev.AgentName),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
progress("thinking_stream_delta", chunk.ReasoningContent, map[string]interface{}{
|
|
||||||
"streamId": reasoningStreamID,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if chunk.Content != "" {
|
return nil, ev.Err
|
||||||
if progress != nil && streamsMainAssistant(ev.AgentName) {
|
|
||||||
if !streamHeaderSent {
|
|
||||||
progress("response_start", "", map[string]interface{}{
|
|
||||||
"conversationId": conversationID,
|
|
||||||
"mcpExecutionIds": snapshotMCPIDs(),
|
|
||||||
"messageGeneratedBy": "eino:" + ev.AgentName,
|
|
||||||
"einoRole": "orchestrator",
|
|
||||||
})
|
|
||||||
streamHeaderSent = true
|
|
||||||
}
|
|
||||||
progress("response_delta", chunk.Content, map[string]interface{}{
|
|
||||||
"conversationId": conversationID,
|
|
||||||
"mcpExecutionIds": snapshotMCPIDs(),
|
|
||||||
"einoRole": "orchestrator",
|
|
||||||
})
|
|
||||||
mainAssistantBuf.WriteString(chunk.Content)
|
|
||||||
} else if !streamsMainAssistant(ev.AgentName) {
|
|
||||||
if progress != nil {
|
|
||||||
if subReplyStreamID == "" {
|
|
||||||
subReplyStreamID = fmt.Sprintf("eino-sub-reply-%s-%d", conversationID, atomic.AddInt64(&einoSubReplyStreamSeq, 1))
|
|
||||||
progress("eino_agent_reply_stream_start", "", map[string]interface{}{
|
|
||||||
"streamId": subReplyStreamID,
|
|
||||||
"einoAgent": ev.AgentName,
|
|
||||||
"einoRole": "sub",
|
|
||||||
"conversationId": conversationID,
|
|
||||||
"source": "eino",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
progress("eino_agent_reply_stream_delta", chunk.Content, map[string]interface{}{
|
|
||||||
"streamId": subReplyStreamID,
|
|
||||||
"conversationId": conversationID,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
subAssistantBuf.WriteString(chunk.Content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 收集流式 tool_calls 全部分片;arguments 在最后一帧常为 "",需按 index/id 合并后才能展示 subagent_type/description。
|
|
||||||
if len(chunk.ToolCalls) > 0 {
|
|
||||||
toolStreamFragments = append(toolStreamFragments, chunk.ToolCalls...)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if streamsMainAssistant(ev.AgentName) {
|
if ev.AgentName != "" && progress != nil {
|
||||||
if s := strings.TrimSpace(mainAssistantBuf.String()); s != "" {
|
if streamsMainAssistant(ev.AgentName) {
|
||||||
lastAssistant = s
|
if einoMainRound == 0 {
|
||||||
}
|
einoMainRound = 1
|
||||||
}
|
progress("iteration", "", map[string]interface{}{
|
||||||
if subAssistantBuf.Len() > 0 && progress != nil {
|
"iteration": 1,
|
||||||
if s := strings.TrimSpace(subAssistantBuf.String()); s != "" {
|
"einoScope": "main",
|
||||||
if subReplyStreamID != "" {
|
"einoRole": "orchestrator",
|
||||||
progress("eino_agent_reply_stream_end", s, map[string]interface{}{
|
"einoAgent": orchestratorName,
|
||||||
"streamId": subReplyStreamID,
|
|
||||||
"einoAgent": ev.AgentName,
|
|
||||||
"einoRole": "sub",
|
|
||||||
"conversationId": conversationID,
|
|
||||||
"source": "eino",
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
progress("eino_agent_reply", s, map[string]interface{}{
|
|
||||||
"conversationId": conversationID,
|
"conversationId": conversationID,
|
||||||
"einoAgent": ev.AgentName,
|
"source": "eino",
|
||||||
"einoRole": "sub",
|
})
|
||||||
"source": "eino",
|
} else if einoLastAgent != "" && !streamsMainAssistant(einoLastAgent) {
|
||||||
|
einoMainRound++
|
||||||
|
progress("iteration", "", map[string]interface{}{
|
||||||
|
"iteration": einoMainRound,
|
||||||
|
"einoScope": "main",
|
||||||
|
"einoRole": "orchestrator",
|
||||||
|
"einoAgent": orchestratorName,
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"source": "eino",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
einoLastAgent = ev.AgentName
|
||||||
var lastToolChunk *schema.Message
|
progress("progress", fmt.Sprintf("[Eino] %s", ev.AgentName), map[string]interface{}{
|
||||||
if merged := mergeStreamingToolCallFragments(toolStreamFragments); len(merged) > 0 {
|
|
||||||
lastToolChunk = &schema.Message{ToolCalls: merged}
|
|
||||||
}
|
|
||||||
tryEmitToolCallsOnce(lastToolChunk, ev.AgentName, orchestratorName, conversationID, progress, toolEmitSeen, subAgentToolStep)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
msg, gerr := mv.GetMessage()
|
|
||||||
if gerr != nil || msg == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
tryEmitToolCallsOnce(mergeMessageToolCalls(msg), ev.AgentName, orchestratorName, conversationID, progress, toolEmitSeen, subAgentToolStep)
|
|
||||||
|
|
||||||
if mv.Role == schema.Assistant {
|
|
||||||
if progress != nil && strings.TrimSpace(msg.ReasoningContent) != "" {
|
|
||||||
progress("thinking", strings.TrimSpace(msg.ReasoningContent), map[string]interface{}{
|
|
||||||
"conversationId": conversationID,
|
"conversationId": conversationID,
|
||||||
"source": "eino",
|
|
||||||
"einoAgent": ev.AgentName,
|
"einoAgent": ev.AgentName,
|
||||||
"einoRole": einoRoleTag(ev.AgentName),
|
"einoRole": einoRoleTag(ev.AgentName),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
body := strings.TrimSpace(msg.Content)
|
if ev.Output == nil || ev.Output.MessageOutput == nil {
|
||||||
if body != "" {
|
continue
|
||||||
if streamsMainAssistant(ev.AgentName) {
|
}
|
||||||
if progress != nil {
|
mv := ev.Output.MessageOutput
|
||||||
progress("response_start", "", map[string]interface{}{
|
|
||||||
"conversationId": conversationID,
|
if mv.IsStreaming && mv.MessageStream != nil {
|
||||||
"mcpExecutionIds": snapshotMCPIDs(),
|
streamHeaderSent := false
|
||||||
"messageGeneratedBy": "eino:" + ev.AgentName,
|
var reasoningStreamID string
|
||||||
"einoRole": "orchestrator",
|
var toolStreamFragments []schema.ToolCall
|
||||||
})
|
var subAssistantBuf strings.Builder
|
||||||
progress("response_delta", body, map[string]interface{}{
|
var subReplyStreamID string
|
||||||
"conversationId": conversationID,
|
var mainAssistantBuf strings.Builder
|
||||||
"mcpExecutionIds": snapshotMCPIDs(),
|
for {
|
||||||
"einoRole": "orchestrator",
|
chunk, rerr := mv.MessageStream.Recv()
|
||||||
|
if rerr != nil {
|
||||||
|
if errors.Is(rerr, io.EOF) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if logger != nil {
|
||||||
|
logger.Warn("eino stream recv", zap.Error(rerr))
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if chunk == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if progress != nil && strings.TrimSpace(chunk.ReasoningContent) != "" {
|
||||||
|
if reasoningStreamID == "" {
|
||||||
|
reasoningStreamID = fmt.Sprintf("eino-reasoning-%s-%d", conversationID, atomic.AddInt64(&reasoningStreamSeq, 1))
|
||||||
|
progress("thinking_stream_start", " ", map[string]interface{}{
|
||||||
|
"streamId": reasoningStreamID,
|
||||||
|
"source": "eino",
|
||||||
|
"einoAgent": ev.AgentName,
|
||||||
|
"einoRole": einoRoleTag(ev.AgentName),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
progress("thinking_stream_delta", chunk.ReasoningContent, map[string]interface{}{
|
||||||
|
"streamId": reasoningStreamID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
lastAssistant = body
|
if chunk.Content != "" {
|
||||||
} else if progress != nil {
|
if progress != nil && streamsMainAssistant(ev.AgentName) {
|
||||||
progress("eino_agent_reply", body, map[string]interface{}{
|
if !streamHeaderSent {
|
||||||
|
progress("response_start", "", map[string]interface{}{
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"mcpExecutionIds": snapshotMCPIDs(),
|
||||||
|
"messageGeneratedBy": "eino:" + ev.AgentName,
|
||||||
|
"einoRole": "orchestrator",
|
||||||
|
})
|
||||||
|
streamHeaderSent = true
|
||||||
|
}
|
||||||
|
progress("response_delta", chunk.Content, map[string]interface{}{
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"mcpExecutionIds": snapshotMCPIDs(),
|
||||||
|
"einoRole": "orchestrator",
|
||||||
|
})
|
||||||
|
mainAssistantBuf.WriteString(chunk.Content)
|
||||||
|
} else if !streamsMainAssistant(ev.AgentName) {
|
||||||
|
if progress != nil {
|
||||||
|
if subReplyStreamID == "" {
|
||||||
|
subReplyStreamID = fmt.Sprintf("eino-sub-reply-%s-%d", conversationID, atomic.AddInt64(&einoSubReplyStreamSeq, 1))
|
||||||
|
progress("eino_agent_reply_stream_start", "", map[string]interface{}{
|
||||||
|
"streamId": subReplyStreamID,
|
||||||
|
"einoAgent": ev.AgentName,
|
||||||
|
"einoRole": "sub",
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"source": "eino",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
progress("eino_agent_reply_stream_delta", chunk.Content, map[string]interface{}{
|
||||||
|
"streamId": subReplyStreamID,
|
||||||
|
"conversationId": conversationID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
subAssistantBuf.WriteString(chunk.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 收集流式 tool_calls 全部分片;arguments 在最后一帧常为 "",需按 index/id 合并后才能展示 subagent_type/description。
|
||||||
|
if len(chunk.ToolCalls) > 0 {
|
||||||
|
toolStreamFragments = append(toolStreamFragments, chunk.ToolCalls...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if streamsMainAssistant(ev.AgentName) {
|
||||||
|
if s := strings.TrimSpace(mainAssistantBuf.String()); s != "" {
|
||||||
|
lastAssistant = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if subAssistantBuf.Len() > 0 && progress != nil {
|
||||||
|
if s := strings.TrimSpace(subAssistantBuf.String()); s != "" {
|
||||||
|
if subReplyStreamID != "" {
|
||||||
|
progress("eino_agent_reply_stream_end", s, map[string]interface{}{
|
||||||
|
"streamId": subReplyStreamID,
|
||||||
|
"einoAgent": ev.AgentName,
|
||||||
|
"einoRole": "sub",
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"source": "eino",
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
progress("eino_agent_reply", s, map[string]interface{}{
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"einoAgent": ev.AgentName,
|
||||||
|
"einoRole": "sub",
|
||||||
|
"source": "eino",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var lastToolChunk *schema.Message
|
||||||
|
if merged := mergeStreamingToolCallFragments(toolStreamFragments); len(merged) > 0 {
|
||||||
|
lastToolChunk = &schema.Message{ToolCalls: merged}
|
||||||
|
}
|
||||||
|
tryEmitToolCallsOnce(lastToolChunk, ev.AgentName, orchestratorName, conversationID, progress, toolEmitSeen, subAgentToolStep)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
msg, gerr := mv.GetMessage()
|
||||||
|
if gerr != nil || msg == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tryEmitToolCallsOnce(mergeMessageToolCalls(msg), ev.AgentName, orchestratorName, conversationID, progress, toolEmitSeen, subAgentToolStep)
|
||||||
|
|
||||||
|
if mv.Role == schema.Assistant {
|
||||||
|
if progress != nil && strings.TrimSpace(msg.ReasoningContent) != "" {
|
||||||
|
progress("thinking", strings.TrimSpace(msg.ReasoningContent), map[string]interface{}{
|
||||||
"conversationId": conversationID,
|
"conversationId": conversationID,
|
||||||
"einoAgent": ev.AgentName,
|
|
||||||
"einoRole": "sub",
|
|
||||||
"source": "eino",
|
"source": "eino",
|
||||||
|
"einoAgent": ev.AgentName,
|
||||||
|
"einoRole": einoRoleTag(ev.AgentName),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
body := strings.TrimSpace(msg.Content)
|
||||||
}
|
if body != "" {
|
||||||
|
if streamsMainAssistant(ev.AgentName) {
|
||||||
if mv.Role == schema.Tool && progress != nil {
|
if progress != nil {
|
||||||
toolName := msg.ToolName
|
progress("response_start", "", map[string]interface{}{
|
||||||
if toolName == "" {
|
"conversationId": conversationID,
|
||||||
toolName = mv.ToolName
|
"mcpExecutionIds": snapshotMCPIDs(),
|
||||||
|
"messageGeneratedBy": "eino:" + ev.AgentName,
|
||||||
|
"einoRole": "orchestrator",
|
||||||
|
})
|
||||||
|
progress("response_delta", body, map[string]interface{}{
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"mcpExecutionIds": snapshotMCPIDs(),
|
||||||
|
"einoRole": "orchestrator",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
lastAssistant = body
|
||||||
|
} else if progress != nil {
|
||||||
|
progress("eino_agent_reply", body, map[string]interface{}{
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"einoAgent": ev.AgentName,
|
||||||
|
"einoRole": "sub",
|
||||||
|
"source": "eino",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// bridge 工具在 res.IsError=true 时会返回带前缀的内容;这里解析为 success/isError,避免前端误判为成功。
|
if mv.Role == schema.Tool && progress != nil {
|
||||||
content := msg.Content
|
toolName := msg.ToolName
|
||||||
isErr := false
|
if toolName == "" {
|
||||||
if strings.HasPrefix(content, einomcp.ToolErrorPrefix) {
|
toolName = mv.ToolName
|
||||||
isErr = true
|
}
|
||||||
content = strings.TrimPrefix(content, einomcp.ToolErrorPrefix)
|
|
||||||
}
|
|
||||||
|
|
||||||
preview := content
|
// bridge 工具在 res.IsError=true 时会返回带前缀的内容;这里解析为 success/isError,避免前端误判为成功。
|
||||||
if len(preview) > 200 {
|
content := msg.Content
|
||||||
preview = preview[:200] + "..."
|
isErr := false
|
||||||
|
if strings.HasPrefix(content, einomcp.ToolErrorPrefix) {
|
||||||
|
isErr = true
|
||||||
|
content = strings.TrimPrefix(content, einomcp.ToolErrorPrefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
preview := content
|
||||||
|
if len(preview) > 200 {
|
||||||
|
preview = preview[:200] + "..."
|
||||||
|
}
|
||||||
|
data := map[string]interface{}{
|
||||||
|
"toolName": toolName,
|
||||||
|
"success": !isErr,
|
||||||
|
"isError": isErr,
|
||||||
|
"result": content,
|
||||||
|
"resultPreview": preview,
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"einoAgent": ev.AgentName,
|
||||||
|
"einoRole": einoRoleTag(ev.AgentName),
|
||||||
|
"source": "eino",
|
||||||
|
}
|
||||||
|
if msg.ToolCallID != "" {
|
||||||
|
data["toolCallId"] = msg.ToolCallID
|
||||||
|
}
|
||||||
|
progress("tool_result", fmt.Sprintf("工具结果 (%s)", toolName), data)
|
||||||
}
|
}
|
||||||
data := map[string]interface{}{
|
|
||||||
"toolName": toolName,
|
|
||||||
"success": !isErr,
|
|
||||||
"isError": isErr,
|
|
||||||
"result": content,
|
|
||||||
"resultPreview": preview,
|
|
||||||
"conversationId": conversationID,
|
|
||||||
"einoAgent": ev.AgentName,
|
|
||||||
"einoRole": einoRoleTag(ev.AgentName),
|
|
||||||
"source": "eino",
|
|
||||||
}
|
|
||||||
if msg.ToolCallID != "" {
|
|
||||||
data["toolCallId"] = msg.ToolCallID
|
|
||||||
}
|
|
||||||
progress("tool_result", fmt.Sprintf("工具结果 (%s)", toolName), data)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -559,7 +601,7 @@ func RunDeepAgent(
|
|||||||
ids := append([]string(nil), mcpIDs...)
|
ids := append([]string(nil), mcpIDs...)
|
||||||
mcpIDsMu.Unlock()
|
mcpIDsMu.Unlock()
|
||||||
|
|
||||||
histJSON, _ := json.Marshal(msgs)
|
histJSON, _ := json.Marshal(lastRunMsgs)
|
||||||
cleaned := strings.TrimSpace(lastAssistant)
|
cleaned := strings.TrimSpace(lastAssistant)
|
||||||
cleaned = dedupeRepeatedParagraphs(cleaned, 80)
|
cleaned = dedupeRepeatedParagraphs(cleaned, 80)
|
||||||
cleaned = dedupeParagraphsByLineFingerprint(cleaned, 100)
|
cleaned = dedupeParagraphsByLineFingerprint(cleaned, 100)
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package multiagent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/cloudwego/eino/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxToolCallArgumentsJSONAttempts 含首次运行:首次 + 自动重试次数。
|
||||||
|
// 例如为 3 表示最多共 3 次完整 DeepAgent 运行(2 次失败后各追加一条纠错提示)。
|
||||||
|
const maxToolCallArgumentsJSONAttempts = 3
|
||||||
|
|
||||||
|
// toolCallArgumentsJSONRetryHint 追加在用户消息后,提示模型输出合法 JSON 工具参数(部分云厂商会在流式阶段校验 arguments)。
|
||||||
|
func toolCallArgumentsJSONRetryHint() *schema.Message {
|
||||||
|
return schema.UserMessage(`[系统提示] 上一次输出中,工具调用的 function.arguments 不是合法 JSON,接口已拒绝。请重新生成:每个 tool call 的 arguments 必须是完整、可解析的 JSON 对象字符串(键名用双引号,无多余逗号,括号配对)。不要输出截断或不完整的 JSON。
|
||||||
|
|
||||||
|
[System] Your previous tool call used invalid JSON in function.arguments and was rejected by the API. Regenerate with strictly valid JSON objects only (double-quoted keys, matched braces, no trailing commas).`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// toolCallArgumentsJSONRecoveryTimelineMessage 供 eino_recovery 事件落库与前端时间线展示。
|
||||||
|
func toolCallArgumentsJSONRecoveryTimelineMessage(attempt int) string {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"接口拒绝了无效的工具参数 JSON。已向对话追加系统提示并要求模型重新生成合法的 function.arguments。"+
|
||||||
|
"当前为第 %d/%d 轮完整运行。\n\n"+
|
||||||
|
"The API rejected invalid JSON in tool arguments. A system hint was appended. This is full run %d of %d.",
|
||||||
|
attempt+1, maxToolCallArgumentsJSONAttempts, attempt+1, maxToolCallArgumentsJSONAttempts,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isRecoverableToolCallArgumentsJSONError 判断是否为「工具参数非合法 JSON」类流式错误,可通过追加提示后重跑一轮。
|
||||||
|
func isRecoverableToolCallArgumentsJSONError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
s := strings.ToLower(err.Error())
|
||||||
|
if !strings.Contains(s, "json") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.Contains(s, "function.arguments") || strings.Contains(s, "function arguments") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.Contains(s, "invalidparameter") && strings.Contains(s, "json") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.Contains(s, "must be in json format") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package multiagent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsRecoverableToolCallArgumentsJSONError(t *testing.T) {
|
||||||
|
yes := errors.New(`failed to receive stream chunk: error, <400> InternalError.Algo.InvalidParameter: The "function.arguments" parameter of the code model must be in JSON format.`)
|
||||||
|
if !isRecoverableToolCallArgumentsJSONError(yes) {
|
||||||
|
t.Fatal("expected recoverable for function.arguments + JSON")
|
||||||
|
}
|
||||||
|
no := errors.New("unrelated network failure")
|
||||||
|
if isRecoverableToolCallArgumentsJSONError(no) {
|
||||||
|
t.Fatal("expected not recoverable")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
@@ -169,6 +172,7 @@
|
|||||||
"taskCancelled": "Task cancelled",
|
"taskCancelled": "Task cancelled",
|
||||||
"unknownTool": "Unknown tool",
|
"unknownTool": "Unknown tool",
|
||||||
"einoAgentReplyTitle": "Sub-agent reply",
|
"einoAgentReplyTitle": "Sub-agent reply",
|
||||||
|
"einoRecoveryTitle": "🔄 Invalid tool JSON · run {{n}}/{{max}} (hint appended)",
|
||||||
"noDescription": "No description",
|
"noDescription": "No description",
|
||||||
"noResponseData": "No response data",
|
"noResponseData": "No response data",
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
@@ -1106,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",
|
||||||
|
|||||||
@@ -138,6 +138,9 @@
|
|||||||
"expandDetail": "展开详情",
|
"expandDetail": "展开详情",
|
||||||
"noProcessDetail": "暂无过程详情(可能执行过快或未触发详细事件)",
|
"noProcessDetail": "暂无过程详情(可能执行过快或未触发详细事件)",
|
||||||
"copyMessageTitle": "复制消息内容",
|
"copyMessageTitle": "复制消息内容",
|
||||||
|
"deleteTurnTitle": "删除本轮对话",
|
||||||
|
"deleteTurnConfirm": "确定删除本轮对话?将同时删除该轮用户消息与助手回复,且无法恢复;下次模型回复将仅基于剩余消息(已保存的上下文快照会清空并按剩余内容重建)。",
|
||||||
|
"deleteTurnFailed": "删除本轮失败",
|
||||||
"emptyGroupConversations": "该分组暂无对话",
|
"emptyGroupConversations": "该分组暂无对话",
|
||||||
"noMatchingConversationsInGroup": "未找到匹配的对话",
|
"noMatchingConversationsInGroup": "未找到匹配的对话",
|
||||||
"noHistoryConversations": "暂无历史对话",
|
"noHistoryConversations": "暂无历史对话",
|
||||||
@@ -169,6 +172,7 @@
|
|||||||
"taskCancelled": "任务已取消",
|
"taskCancelled": "任务已取消",
|
||||||
"unknownTool": "未知工具",
|
"unknownTool": "未知工具",
|
||||||
"einoAgentReplyTitle": "子代理回复",
|
"einoAgentReplyTitle": "子代理回复",
|
||||||
|
"einoRecoveryTitle": "🔄 工具参数无效 · 第 {{n}}/{{max}} 轮(已追加提示)",
|
||||||
"noDescription": "暂无描述",
|
"noDescription": "暂无描述",
|
||||||
"noResponseData": "暂无响应数据",
|
"noResponseData": "暂无响应数据",
|
||||||
"loading": "加载中...",
|
"loading": "加载中...",
|
||||||
@@ -1106,6 +1110,7 @@
|
|||||||
"folderPathCopied": "目录路径已复制,可粘贴到对话中",
|
"folderPathCopied": "目录路径已复制,可粘贴到对话中",
|
||||||
"folderEmpty": "此文件夹为空",
|
"folderEmpty": "此文件夹为空",
|
||||||
"confirmDeleteFolder": "确定删除该文件夹及其中的全部文件?此操作不可恢复。",
|
"confirmDeleteFolder": "确定删除该文件夹及其中的全部文件?此操作不可恢复。",
|
||||||
|
"folderRemovedStale": "服务器上已无该目录,列表已刷新。",
|
||||||
"deleteFolderTitle": "删除文件夹",
|
"deleteFolderTitle": "删除文件夹",
|
||||||
"uploadToFolderTitle": "上传文件到此文件夹",
|
"uploadToFolderTitle": "上传文件到此文件夹",
|
||||||
"newFolderButton": "新建文件夹",
|
"newFolderButton": "新建文件夹",
|
||||||
|
|||||||
+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')
|
||||||
|
|||||||
@@ -1741,6 +1741,9 @@ function renderProcessDetails(messageId, processDetails) {
|
|||||||
itemTitle = agPx + (typeof window.t === 'function' ? window.t('chat.iterationRound', { n: data.iteration || 1 }) : '第 ' + (data.iteration || 1) + ' 轮迭代');
|
itemTitle = agPx + (typeof window.t === 'function' ? window.t('chat.iterationRound', { n: data.iteration || 1 }) : '第 ' + (data.iteration || 1) + ' 轮迭代');
|
||||||
} else if (eventType === 'thinking') {
|
} else if (eventType === 'thinking') {
|
||||||
itemTitle = agPx + '🤔 ' + (typeof window.t === 'function' ? window.t('chat.aiThinking') : 'AI思考');
|
itemTitle = agPx + '🤔 ' + (typeof window.t === 'function' ? window.t('chat.aiThinking') : 'AI思考');
|
||||||
|
} else if (eventType === 'planning') {
|
||||||
|
// 与流式 monitor.js 中 response_start/response_delta 展示的「规划中」一致(落库聚合)
|
||||||
|
itemTitle = agPx + '📝 ' + (typeof window.t === 'function' ? window.t('chat.planning') : '规划中');
|
||||||
} else if (eventType === 'tool_calls_detected') {
|
} else if (eventType === 'tool_calls_detected') {
|
||||||
itemTitle = agPx + '🔧 ' + (typeof window.t === 'function' ? window.t('chat.toolCallsDetected', { count: data.count || 0 }) : '检测到 ' + (data.count || 0) + ' 个工具调用');
|
itemTitle = agPx + '🔧 ' + (typeof window.t === 'function' ? window.t('chat.toolCallsDetected', { count: data.count || 0 }) : '检测到 ' + (data.count || 0) + ' 个工具调用');
|
||||||
} else if (eventType === 'tool_call') {
|
} else if (eventType === 'tool_call') {
|
||||||
@@ -1760,6 +1763,10 @@ function renderProcessDetails(messageId, processDetails) {
|
|||||||
itemTitle = agPx + execLine;
|
itemTitle = agPx + execLine;
|
||||||
} else if (eventType === 'eino_agent_reply') {
|
} else if (eventType === 'eino_agent_reply') {
|
||||||
itemTitle = agPx + '💬 ' + (typeof window.t === 'function' ? window.t('chat.einoAgentReplyTitle') : '子代理回复');
|
itemTitle = agPx + '💬 ' + (typeof window.t === 'function' ? window.t('chat.einoAgentReplyTitle') : '子代理回复');
|
||||||
|
} else if (eventType === 'eino_recovery') {
|
||||||
|
const ri = data.runIndex != null ? data.runIndex : (data.einoRetry != null ? data.einoRetry + 1 : 1);
|
||||||
|
const mx = data.maxRuns != null ? data.maxRuns : 3;
|
||||||
|
itemTitle = (typeof window.t === 'function' ? window.t('chat.einoRecoveryTitle', { n: ri, max: mx }) : ('🔄 第 ' + ri + '/' + mx + ' 轮(已追加提示)'));
|
||||||
} else if (eventType === 'knowledge_retrieval') {
|
} else if (eventType === 'knowledge_retrieval') {
|
||||||
itemTitle = '📚 ' + (typeof window.t === 'function' ? window.t('chat.knowledgeRetrieval') : '知识检索');
|
itemTitle = '📚 ' + (typeof window.t === 'function' ? window.t('chat.knowledgeRetrieval') : '知识检索');
|
||||||
} else if (eventType === 'error') {
|
} else if (eventType === 'error') {
|
||||||
@@ -2445,6 +2452,7 @@ async function loadConversation(conversationId) {
|
|||||||
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也要显示展开详情按钮)
|
// 对于助手消息,总是渲染过程详情(即使没有processDetails也要显示展开详情按钮)
|
||||||
if (msg.role === 'assistant') {
|
if (msg.role === 'assistant') {
|
||||||
@@ -2484,6 +2492,67 @@ async function loadConversation(conversationId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 「删除本轮」:与时间戳同一行(message-meta-footer),风格与复制按钮区区分 */
|
||||||
|
function attachDeleteTurnButton(messageEl) {
|
||||||
|
if (!messageEl || !messageEl.dataset.backendMessageId) return;
|
||||||
|
if (messageEl.querySelector('.message-delete-turn-btn')) return;
|
||||||
|
const content = messageEl.querySelector('.message-content');
|
||||||
|
if (!content) return;
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.className = 'message-delete-turn-btn';
|
||||||
|
const title = typeof window.t === 'function' ? window.t('chat.deleteTurnTitle') : '删除本轮对话';
|
||||||
|
btn.title = title;
|
||||||
|
btn.setAttribute('aria-label', title);
|
||||||
|
btn.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6h14zM10 11v6M14 11v6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
||||||
|
btn.onclick = function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
deleteConversationTurnFromUI(messageEl.dataset.backendMessageId);
|
||||||
|
};
|
||||||
|
const timeDiv = content.querySelector('.message-time');
|
||||||
|
let footer = content.querySelector('.message-meta-footer');
|
||||||
|
if (!footer && timeDiv && timeDiv.parentNode === content) {
|
||||||
|
footer = document.createElement('div');
|
||||||
|
footer.className = 'message-meta-footer';
|
||||||
|
timeDiv.parentNode.insertBefore(footer, timeDiv);
|
||||||
|
footer.appendChild(timeDiv);
|
||||||
|
}
|
||||||
|
if (footer) {
|
||||||
|
footer.appendChild(btn);
|
||||||
|
} else {
|
||||||
|
content.appendChild(btn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除锚点所在整轮(后端:该轮 user 至下一轮 user 之前),并清空 ReAct 快照 */
|
||||||
|
async function deleteConversationTurnFromUI(anchorBackendMessageId) {
|
||||||
|
if (!currentConversationId || !anchorBackendMessageId) return;
|
||||||
|
const confirmMsg = typeof window.t === 'function' ? window.t('chat.deleteTurnConfirm') : '确定删除本轮对话?';
|
||||||
|
if (!confirm(confirmMsg)) return;
|
||||||
|
try {
|
||||||
|
const response = await apiFetch(`/api/conversations/${currentConversationId}/delete-turn`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ messageId: anchorBackendMessageId })
|
||||||
|
});
|
||||||
|
let data = {};
|
||||||
|
try {
|
||||||
|
data = await response.json();
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.error || data.message || 'delete failed');
|
||||||
|
}
|
||||||
|
await loadConversation(currentConversationId);
|
||||||
|
if (typeof loadConversations === 'function') loadConversations();
|
||||||
|
if (typeof loadConversationsWithGroups === 'function') loadConversationsWithGroups();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('delete turn failed:', error);
|
||||||
|
const failed = typeof window.t === 'function' ? window.t('chat.deleteTurnFailed') : '删除本轮失败';
|
||||||
|
alert(failed + ': ' + (error && error.message ? error.message : error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 删除对话
|
// 删除对话
|
||||||
async function deleteConversation(conversationId, skipConfirm = false) {
|
async function deleteConversation(conversationId, skipConfirm = false) {
|
||||||
// 确认删除(如果调用者没有跳过确认)
|
// 确认删除(如果调用者没有跳过确认)
|
||||||
|
|||||||
@@ -700,10 +700,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;
|
||||||
|
|
||||||
@@ -889,7 +924,22 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
data: event.data
|
data: event.data
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'eino_recovery': {
|
||||||
|
const d = event.data || {};
|
||||||
|
const runIdx = d.runIndex != null ? d.runIndex : (d.einoRetry != null ? d.einoRetry + 1 : 1);
|
||||||
|
const maxRuns = d.maxRuns != null ? d.maxRuns : 3;
|
||||||
|
const title = typeof window.t === 'function'
|
||||||
|
? window.t('chat.einoRecoveryTitle', { n: runIdx, max: maxRuns })
|
||||||
|
: ('🔄 工具参数无效 · 第 ' + runIdx + '/' + maxRuns + ' 轮(已追加提示)');
|
||||||
|
addTimelineItem(timeline, 'eino_recovery', {
|
||||||
|
title: title,
|
||||||
|
message: event.message || '',
|
||||||
|
data: event.data
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'tool_call':
|
case 'tool_call':
|
||||||
const toolInfo = event.data || {};
|
const toolInfo = event.data || {};
|
||||||
const toolName = toolInfo.toolName || (typeof window.t === 'function' ? window.t('chat.unknownTool') : '未知工具');
|
const toolName = toolInfo.toolName || (typeof window.t === 'function' ? window.t('chat.unknownTool') : '未知工具');
|
||||||
@@ -1158,6 +1208,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)) {
|
||||||
@@ -1291,6 +1344,11 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
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);
|
||||||
@@ -1329,6 +1387,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)) {
|
||||||
@@ -1463,6 +1524,15 @@ function addTimelineItem(timeline, type, options) {
|
|||||||
if (type === 'progress' && options.message) {
|
if (type === 'progress' && options.message) {
|
||||||
item.dataset.progressMessage = options.message;
|
item.dataset.progressMessage = options.message;
|
||||||
}
|
}
|
||||||
|
if (type === 'eino_recovery' && options.data) {
|
||||||
|
const d = options.data;
|
||||||
|
if (d.runIndex != null) {
|
||||||
|
item.dataset.recoveryRunIndex = String(d.runIndex);
|
||||||
|
}
|
||||||
|
if (d.maxRuns != null) {
|
||||||
|
item.dataset.recoveryMaxRuns = String(d.maxRuns);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (type === 'tool_calls_detected' && options.data && options.data.count != null) {
|
if (type === 'tool_calls_detected' && options.data && options.data.count != null) {
|
||||||
item.dataset.toolCallsCount = String(options.data.count);
|
item.dataset.toolCallsCount = String(options.data.count);
|
||||||
}
|
}
|
||||||
@@ -1516,7 +1586,7 @@ function addTimelineItem(timeline, type, options) {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
// 根据类型添加详细内容
|
// 根据类型添加详细内容
|
||||||
if (type === 'thinking' && options.message) {
|
if ((type === 'thinking' || type === 'planning') && options.message) {
|
||||||
content += `<div class="timeline-item-content">${formatMarkdown(options.message)}</div>`;
|
content += `<div class="timeline-item-content">${formatMarkdown(options.message)}</div>`;
|
||||||
} else if (type === 'tool_call' && options.data) {
|
} else if (type === 'tool_call' && options.data) {
|
||||||
const data = options.data;
|
const data = options.data;
|
||||||
@@ -1561,6 +1631,12 @@ function addTimelineItem(timeline, type, options) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
} else if (type === 'eino_recovery' && options.message) {
|
||||||
|
content += `
|
||||||
|
<div class="timeline-item-content timeline-eino-recovery">
|
||||||
|
${escapeHtml(options.message).replace(/\n/g, '<br>')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
} else if (type === 'cancelled') {
|
} else if (type === 'cancelled') {
|
||||||
const taskCancelledLabel = typeof window.t === 'function' ? window.t('chat.taskCancelled') : '任务已取消';
|
const taskCancelledLabel = typeof window.t === 'function' ? window.t('chat.taskCancelled') : '任务已取消';
|
||||||
content += `
|
content += `
|
||||||
@@ -1569,7 +1645,7 @@ function addTimelineItem(timeline, type, options) {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
item.innerHTML = content;
|
item.innerHTML = content;
|
||||||
if (options.data) {
|
if (options.data) {
|
||||||
applyEinoTimelineRole(item, options.data);
|
applyEinoTimelineRole(item, options.data);
|
||||||
@@ -2390,6 +2466,8 @@ function refreshProgressAndTimelineI18n() {
|
|||||||
}
|
}
|
||||||
} else if (type === 'thinking') {
|
} else if (type === 'thinking') {
|
||||||
titleSpan.textContent = ap + '\uD83E\uDD14 ' + _t('chat.aiThinking');
|
titleSpan.textContent = ap + '\uD83E\uDD14 ' + _t('chat.aiThinking');
|
||||||
|
} else if (type === 'planning') {
|
||||||
|
titleSpan.textContent = ap + '\uD83D\uDCDD ' + _t('chat.planning');
|
||||||
} else if (type === 'tool_calls_detected' && item.dataset.toolCallsCount != null) {
|
} else if (type === 'tool_calls_detected' && item.dataset.toolCallsCount != null) {
|
||||||
const count = parseInt(item.dataset.toolCallsCount, 10) || 0;
|
const count = parseInt(item.dataset.toolCallsCount, 10) || 0;
|
||||||
titleSpan.textContent = ap + '\uD83D\uDD27 ' + _t('chat.toolCallsDetected', { count: count });
|
titleSpan.textContent = ap + '\uD83D\uDD27 ' + _t('chat.toolCallsDetected', { count: count });
|
||||||
@@ -2405,6 +2483,10 @@ function refreshProgressAndTimelineI18n() {
|
|||||||
titleSpan.textContent = ap + icon + (success ? _t('chat.toolExecComplete', { name: name }) : _t('chat.toolExecFailed', { name: name }));
|
titleSpan.textContent = ap + icon + (success ? _t('chat.toolExecComplete', { name: name }) : _t('chat.toolExecFailed', { name: name }));
|
||||||
} else if (type === 'eino_agent_reply') {
|
} else if (type === 'eino_agent_reply') {
|
||||||
titleSpan.textContent = ap + '\uD83D\uDCAC ' + _t('chat.einoAgentReplyTitle');
|
titleSpan.textContent = ap + '\uD83D\uDCAC ' + _t('chat.einoAgentReplyTitle');
|
||||||
|
} else if (type === 'eino_recovery' && item.dataset.recoveryRunIndex) {
|
||||||
|
const n = parseInt(item.dataset.recoveryRunIndex, 10) || 1;
|
||||||
|
const mx = parseInt(item.dataset.recoveryMaxRuns, 10) || 3;
|
||||||
|
titleSpan.textContent = _t('chat.einoRecoveryTitle', { n: n, max: mx });
|
||||||
} else if (type === 'cancelled') {
|
} else if (type === 'cancelled') {
|
||||||
titleSpan.textContent = '\u26D4 ' + _t('chat.taskCancelled');
|
titleSpan.textContent = '\u26D4 ' + _t('chat.taskCancelled');
|
||||||
} else if (type === 'progress' && item.dataset.progressMessage !== undefined) {
|
} else if (type === 'progress' && item.dataset.progressMessage !== undefined) {
|
||||||
|
|||||||
Reference in New Issue
Block a user