Compare commits

...

17 Commits

Author SHA1 Message Date
公明 f196992b91 Update config.yaml 2026-04-02 00:41:46 +08:00
公明 f64b7653ac Add files via upload 2026-04-02 00:40:12 +08:00
公明 2a9b18ba7b Add files via upload 2026-04-02 00:38:24 +08:00
公明 6f70d7b851 Add files via upload 2026-04-02 00:01:13 +08:00
公明 157f1c9754 Add files via upload 2026-04-01 23:57:51 +08:00
公明 0c95ed03c2 Update config.yaml 2026-03-31 22:37:36 +08:00
公明 2772c4d9e7 Add files via upload 2026-03-31 22:25:11 +08:00
公明 1eb5133492 Add files via upload 2026-03-31 22:13:47 +08:00
公明 60fa266af6 Add files via upload 2026-03-31 22:10:39 +08:00
公明 b75b5be1f7 Merge pull request #90 from Amywith/docs/fix-clone-path-readme
docs: fix quick start clone directory
2026-03-31 10:25:11 +08:00
zhongjiemei (Amywith) 1e4b846be5 docs: fix clone directory in quick start 2026-03-30 16:42:36 +08:00
公明 335be9ab03 Add files via upload 2026-03-30 11:30:00 +08:00
公明 32b29b0a5f Update config.yaml 2026-03-29 03:26:11 +08:00
公明 748ce73395 Add files via upload 2026-03-29 03:25:41 +08:00
公明 e0c9a3bd8e Add files via upload 2026-03-29 03:24:22 +08:00
公明 324ac638d9 Add files via upload 2026-03-29 01:44:49 +08:00
公明 f988b9f611 Add files via upload 2026-03-29 01:42:23 +08:00
23 changed files with 1315 additions and 332 deletions
+1 -1
View File
@@ -149,7 +149,7 @@ CyberStrikeAI ships with 100+ curated tools covering the whole kill chain:
**One-Command Deployment:**
```bash
git clone https://github.com/Ed1s0nZ/CyberStrikeAI.git
cd CyberStrikeAI-main
cd CyberStrikeAI
chmod +x run.sh && ./run.sh
```
+1 -1
View File
@@ -148,7 +148,7 @@ CyberStrikeAI 是一款 **AI 原生安全测试平台**,基于 Go 构建,集
**一条命令部署:**
```bash
git clone https://github.com/Ed1s0nZ/CyberStrikeAI.git
cd CyberStrikeAI-main
cd CyberStrikeAI
chmod +x run.sh && ./run.sh
```
+1 -1
View File
@@ -10,7 +10,7 @@
# ============================================
# 前端显示的版本号(可选,不填则显示默认版本)
version: "v1.4.4"
version: "v1.4.7"
# 服务器配置
server:
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
+1
View File
@@ -661,6 +661,7 @@ func setupRoutes(
protected.POST("/conversations", conversationHandler.CreateConversation)
protected.GET("/conversations", conversationHandler.ListConversations)
protected.GET("/conversations/:id", conversationHandler.GetConversation)
protected.GET("/messages/:id/process-details", conversationHandler.GetMessageProcessDetails)
protected.PUT("/conversations/:id", conversationHandler.UpdateConversation)
protected.DELETE("/conversations/:id", conversationHandler.DeleteConversation)
protected.PUT("/conversations/:id/pinned", groupHandler.UpdateConversationPinned)
+47
View File
@@ -256,6 +256,53 @@ func (db *DB) GetConversation(id string) (*Conversation, error) {
return &conv, nil
}
// GetConversationLite 获取对话(轻量版):包含 messages,但不加载 process_details。
// 用于历史会话快速切换,避免一次性把大体量过程详情灌到前端导致卡顿。
func (db *DB) GetConversationLite(id string) (*Conversation, error) {
var conv Conversation
var createdAt, updatedAt string
var pinned int
err := db.QueryRow(
"SELECT id, title, pinned, created_at, updated_at FROM conversations WHERE id = ?",
id,
).Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("对话不存在")
}
return nil, fmt.Errorf("查询对话失败: %w", err)
}
// 尝试多种时间格式解析
var err1, err2 error
conv.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05.999999999-07:00", createdAt)
if err1 != nil {
conv.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05", createdAt)
}
if err1 != nil {
conv.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
}
conv.UpdatedAt, err2 = time.Parse("2006-01-02 15:04:05.999999999-07:00", updatedAt)
if err2 != nil {
conv.UpdatedAt, err2 = time.Parse("2006-01-02 15:04:05", updatedAt)
}
if err2 != nil {
conv.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
}
conv.Pinned = pinned != 0
// 加载消息(不加载 process_details
messages, err := db.GetMessages(id)
if err != nil {
return nil, fmt.Errorf("加载消息失败: %w", err)
}
conv.Messages = messages
return &conv, nil
}
// ListConversations 列出所有对话
func (db *DB) ListConversations(limit, offset int, search string) ([]*Conversation, error) {
var rows *sql.Rows
+41 -10
View File
@@ -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) {
_ = 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{}
if argumentsInJSON != "" && argumentsInJSON != "null" {
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{}{}
}
// Stream tool output (stdout/stderr) to upper layer via security.Executor's callback.
// This enables multi-agent mode to show execution progress on the frontend.
if m.chunk != nil {
if chunk != nil {
toolCallID := compose.GetToolCallID(ctx)
if toolCallID != "" {
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) {
existing(c)
if strings.TrimSpace(c) == "" {
return
}
m.chunk(m.name, toolCallID, c)
chunk(toolName, toolCallID, c)
}))
} else {
ctx = context.WithValue(ctx, security.ToolOutputCallbackCtxKey, security.ToolOutputCallback(func(c string) {
if strings.TrimSpace(c) == "" {
return
}
m.chunk(m.name, toolCallID, c)
chunk(toolName, toolCallID, c)
}))
}
}
}
conv := m.holder.Get()
res, err := m.agent.ExecuteMCPToolForConversation(ctx, conv, m.name, args)
res, err := ag.ExecuteMCPToolForConversation(ctx, holder.Get(), toolName, args)
if err != nil {
return "", err
}
if res == nil {
return "", nil
}
if res.ExecutionID != "" && m.record != nil {
m.record(res.ExecutionID)
if res.ExecutionID != "" && record != nil {
record(res.ExecutionID)
}
if res.IsError {
return ToolErrorPrefix + 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)
}
+16
View File
@@ -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")
}
}
+285 -15
View File
@@ -12,6 +12,7 @@ import (
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"unicode/utf8"
@@ -78,8 +79,8 @@ type AgentHandler struct {
knowledgeManager interface { // 知识库管理器接口
LogRetrieval(conversationID, messageID, query, riskType string, retrievedItems []string) error
}
skillsManager *skills.Manager // Skills管理器
agentsMarkdownDir string // 多代理:Markdown 子 Agent 目录(绝对路径,空则不从磁盘合并)
skillsManager *skills.Manager // Skills管理器
agentsMarkdownDir string // 多代理:Markdown 子 Agent 目录(绝对路径,空则不从磁盘合并)
}
// NewAgentHandler 创建新的Agent处理器
@@ -121,9 +122,10 @@ func (h *AgentHandler) SetAgentsMarkdownDir(absDir string) {
// ChatAttachment 聊天附件(用户上传的文件)
type ChatAttachment struct {
FileName string `json:"fileName"` // 文件名
Content string `json:"content"` // 文本内容或 base64(由 MimeType 决定是否解码)
MimeType string `json:"mimeType,omitempty"`
FileName string `json:"fileName"` // 展示用文件名
Content string `json:"content,omitempty"` // 文本或 base64;若已预先上传到服务器可留空
MimeType string `json:"mimeType,omitempty"`
ServerPath string `json:"serverPath,omitempty"` // 已保存在 chat_uploads 下的绝对路径(由 POST /api/chat-uploads 返回)
}
// ChatRequest 聊天请求
@@ -140,7 +142,115 @@ const (
chatUploadsDirName = "chat_uploads" // 对话附件保存的根目录(相对当前工作目录)
)
// saveAttachmentsToDateAndConversationDir 将附件保存到 chat_uploads/YYYY-MM-DD/{conversationID}/,返回每个文件的保存路径(与 attachments 顺序一致
// validateChatAttachmentServerPath 校验绝对路径落在工作目录 chat_uploads 下且为普通文件(防路径穿越
func validateChatAttachmentServerPath(abs string) (string, error) {
p := strings.TrimSpace(abs)
if p == "" {
return "", fmt.Errorf("empty path")
}
cwd, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("获取当前工作目录失败: %w", err)
}
root := filepath.Join(cwd, chatUploadsDirName)
rootAbs, err := filepath.Abs(filepath.Clean(root))
if err != nil {
return "", err
}
pathAbs, err := filepath.Abs(filepath.Clean(p))
if err != nil {
return "", err
}
sep := string(filepath.Separator)
if pathAbs != rootAbs && !strings.HasPrefix(pathAbs, rootAbs+sep) {
return "", fmt.Errorf("path outside chat_uploads")
}
st, err := os.Stat(pathAbs)
if err != nil {
return "", err
}
if st.IsDir() {
return "", fmt.Errorf("not a regular file")
}
return pathAbs, nil
}
// avoidChatUploadDestCollision 若 path 已存在则生成带时间戳+随机后缀的新文件名(与上传接口命名风格一致)
func avoidChatUploadDestCollision(path string) string {
if _, err := os.Stat(path); os.IsNotExist(err) {
return path
}
dir := filepath.Dir(path)
base := filepath.Base(path)
ext := filepath.Ext(base)
nameNoExt := strings.TrimSuffix(base, ext)
suffix := fmt.Sprintf("_%s_%s", time.Now().Format("150405"), shortRand(6))
var unique string
if ext != "" {
unique = nameNoExt + suffix + ext
} else {
unique = base + suffix
}
return filepath.Join(dir, unique)
}
// relocateManualOrNewUploadToConversation 无会话 ID 时前端会上传到 …/日期/_manual;首条消息创建会话后,将文件移入 …/日期/{conversationId}/ 以便按对话隔离。
func relocateManualOrNewUploadToConversation(absPath, conversationID string, logger *zap.Logger) (string, error) {
conv := strings.TrimSpace(conversationID)
if conv == "" {
return absPath, nil
}
convSan := strings.ReplaceAll(conv, string(filepath.Separator), "_")
if convSan == "" || convSan == "_manual" || convSan == "_new" {
return absPath, nil
}
cwd, err := os.Getwd()
if err != nil {
return absPath, err
}
rootAbs, err := filepath.Abs(filepath.Join(cwd, chatUploadsDirName))
if err != nil {
return absPath, err
}
rel, err := filepath.Rel(rootAbs, absPath)
if err != nil {
return absPath, nil
}
rel = filepath.ToSlash(filepath.Clean(rel))
var segs []string
for _, p := range strings.Split(rel, "/") {
if p != "" && p != "." {
segs = append(segs, p)
}
}
// 仅处理扁平结构:日期/_manual|_new/文件名
if len(segs) != 3 {
return absPath, nil
}
datePart, placeFolder, baseName := segs[0], segs[1], segs[2]
if placeFolder != "_manual" && placeFolder != "_new" {
return absPath, nil
}
targetDir := filepath.Join(rootAbs, datePart, convSan)
if err := os.MkdirAll(targetDir, 0755); err != nil {
return "", fmt.Errorf("创建会话附件目录失败: %w", err)
}
dest := filepath.Join(targetDir, baseName)
dest = avoidChatUploadDestCollision(dest)
if err := os.Rename(absPath, dest); err != nil {
return "", fmt.Errorf("将附件移入会话目录失败: %w", err)
}
out, _ := filepath.Abs(dest)
if logger != nil {
logger.Info("对话附件已从占位目录移入会话目录",
zap.String("from", absPath),
zap.String("to", out),
zap.String("conversationId", conv))
}
return out, nil
}
// saveAttachmentsToDateAndConversationDir 处理附件:若带 serverPath 则仅校验已存在文件;否则将 content 写入 chat_uploads/YYYY-MM-DD/{conversationID}/。
// conversationID 为空时使用 "_new" 作为目录名(新对话尚未有 ID)
func saveAttachmentsToDateAndConversationDir(attachments []ChatAttachment, conversationID string, logger *zap.Logger) (savedPaths []string, err error) {
if len(attachments) == 0 {
@@ -163,6 +273,24 @@ func saveAttachmentsToDateAndConversationDir(attachments []ChatAttachment, conve
}
savedPaths = make([]string, 0, len(attachments))
for i, a := range attachments {
if sp := strings.TrimSpace(a.ServerPath); sp != "" {
valid, verr := validateChatAttachmentServerPath(sp)
if verr != nil {
return nil, fmt.Errorf("附件 %s: %w", a.FileName, verr)
}
finalPath, rerr := relocateManualOrNewUploadToConversation(valid, conversationID, logger)
if rerr != nil {
return nil, fmt.Errorf("附件 %s: %w", a.FileName, rerr)
}
savedPaths = append(savedPaths, finalPath)
if logger != nil {
logger.Debug("对话附件使用已上传路径", zap.Int("index", i+1), zap.String("fileName", a.FileName), zap.String("path", finalPath))
}
continue
}
if strings.TrimSpace(a.Content) == "" {
return nil, fmt.Errorf("附件 %s 缺少内容或未提供 serverPath", a.FileName)
}
raw, decErr := attachmentContentToBytes(a)
if decErr != nil {
return nil, fmt.Errorf("附件 %s 解码失败: %w", a.FileName, decErr)
@@ -586,6 +714,73 @@ func (h *AgentHandler) createProgressCallback(conversationID, assistantMessageID
// 用于保存tool_call事件中的参数,以便在tool_result时使用
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{}) {
// 如果提供了sendEventFunc,发送流式事件
if sendEventFunc != nil {
@@ -718,25 +913,97 @@ func (h *AgentHandler) createProgressCallback(conversationID, assistantMessageID
// 子代理回复流式增量不落库;结束时合并为一条 eino_agent_reply
if assistantMessageID != "" && eventType == "eino_agent_reply_stream_end" {
flushResponsePlan()
// 确保思考流在子代理回复前能持久化(刷新后可读)
flushThinkingStreams()
if err := h.db.AddProcessDetail(assistantMessageID, conversationID, "eino_agent_reply", message, data); err != nil {
h.logger.Warn("保存过程详情失败", zap.Error(err), zap.String("eventType", eventType))
}
return
}
// 保存过程详情到数据库(排除response/done事件,它们会在后面单独处理)
// 另外:response_start/response_delta 是模型流式增量,保存会导致过程详情膨胀,因此不落库。
// 多代理主代理「规划中」:response_start / response_delta 仅用于 SSE,聚合落一条 planning
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/doneresponse 正文已在 messages 表)
// response_start/response_delta 已聚合为 planning,不落逐条。
if assistantMessageID != "" &&
eventType != "response" &&
eventType != "done" &&
eventType != "response_start" &&
eventType != "response_delta" &&
eventType != "tool_result_delta" &&
eventType != "thinking_stream_start" &&
eventType != "thinking_stream_delta" &&
eventType != "eino_agent_reply_stream_start" &&
eventType != "eino_agent_reply_stream_delta" &&
eventType != "eino_agent_reply_stream_end" {
// 在关键过程事件落库前,先把「规划中」与 thinking_stream 落库
flushResponsePlan()
flushThinkingStreams()
if err := h.db.AddProcessDetail(assistantMessageID, conversationID, eventType, message, data); err != nil {
h.logger.Warn("保存过程详情失败", zap.Error(err), zap.String("eventType", eventType))
}
@@ -776,6 +1043,8 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
// 发送初始事件
// 用于跟踪客户端是否已断开连接
clientDisconnected := false
// 与 sseKeepalive 共用:禁止并发写 ResponseWriter,否则会破坏 chunked 编码(ERR_INVALID_CHUNKED_ENCODING)。
var sseWriteMu sync.Mutex
// 用于快速确认模型是否真的产生了流式 delta
var responseDeltaCount int
var responseStartLogged bool
@@ -843,19 +1112,20 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
}
eventJSON, _ := json.Marshal(event)
// 尝试写入事件,如果失败则标记客户端断开
if _, err := fmt.Fprintf(c.Writer, "data: %s\n\n", eventJSON); err != nil {
sseWriteMu.Lock()
_, err := fmt.Fprintf(c.Writer, "data: %s\n\n", eventJSON)
if err != nil {
sseWriteMu.Unlock()
clientDisconnected = true
h.logger.Debug("客户端断开连接,停止发送SSE事件", zap.Error(err))
return
}
// 刷新响应,如果失败则标记客户端断开
if flusher, ok := c.Writer.(http.Flusher); ok {
flusher.Flush()
} else {
c.Writer.Flush()
}
sseWriteMu.Unlock()
}
// 如果没有对话ID,创建新对话(WebShell 助手模式下关联连接 ID 以便持久化展示)
@@ -1066,7 +1336,7 @@ func (h *AgentHandler) AgentLoopStream(c *gin.Context) {
sendEvent("progress", "正在分析您的请求...", nil)
// 注意:roleSkills 已在上方根据 req.Role 或 WebShell 模式设置
stopKeepalive := make(chan struct{})
go sseKeepalive(c, stopKeepalive)
go sseKeepalive(c, stopKeepalive, &sseWriteMu)
defer close(stopKeepalive)
result, err := h.agent.AgentLoopWithProgress(taskCtx, finalMessage, agentHistoryMessages, conversationID, progressCallback, roleTools, roleSkills)
+4 -2
View File
@@ -86,8 +86,10 @@ func (h *ChatUploadsHandler) List(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if _, err := os.Stat(root); os.IsNotExist(err) {
c.JSON(http.StatusOK, gin.H{"files": []ChatUploadFileItem{}})
// 保证根目录存在,否则「按文件夹」浏览时无法 mkdir,且首次列表为空时界面无路径工具栏
if err := os.MkdirAll(root, 0755); err != nil {
h.logger.Warn("创建 chat_uploads 根目录失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
var files []ChatUploadFileItem
+53 -1
View File
@@ -1,6 +1,7 @@
package handler
import (
"encoding/json"
"net/http"
"strconv"
@@ -78,7 +79,20 @@ func (h *ConversationHandler) ListConversations(c *gin.Context) {
func (h *ConversationHandler) GetConversation(c *gin.Context) {
id := c.Param("id")
conv, err := h.db.GetConversation(id)
// 默认轻量加载,只有用户需要展开详情时再按需拉取
// include_process_details=1/true 时返回全量 processDetails(兼容旧行为)
includeStr := c.DefaultQuery("include_process_details", "0")
include := includeStr == "1" || includeStr == "true" || includeStr == "yes"
var (
conv *database.Conversation
err error
)
if include {
conv, err = h.db.GetConversation(id)
} else {
conv, err = h.db.GetConversationLite(id)
}
if err != nil {
h.logger.Error("获取对话失败", zap.Error(err))
c.JSON(http.StatusNotFound, gin.H{"error": "对话不存在"})
@@ -88,6 +102,44 @@ func (h *ConversationHandler) GetConversation(c *gin.Context) {
c.JSON(http.StatusOK, conv)
}
// GetMessageProcessDetails 获取指定消息的过程详情(按需加载)
func (h *ConversationHandler) GetMessageProcessDetails(c *gin.Context) {
messageID := c.Param("id")
if messageID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "message id required"})
return
}
details, err := h.db.GetProcessDetails(messageID)
if err != nil {
h.logger.Error("获取过程详情失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// 转换为前端期望的 JSON 结构(与 GetConversation 中 processDetails 结构一致)
out := make([]map[string]interface{}, 0, len(details))
for _, d := range details {
var data interface{}
if d.Data != "" {
if err := json.Unmarshal([]byte(d.Data), &data); err != nil {
h.logger.Warn("解析过程详情数据失败", zap.Error(err))
}
}
out = append(out, map[string]interface{}{
"id": d.ID,
"messageId": d.MessageID,
"conversationId": d.ConversationID,
"eventType": d.EventType,
"message": d.Message,
"data": data,
"createdAt": d.CreatedAt,
})
}
c.JSON(http.StatusOK, gin.H{"processDetails": out})
}
// UpdateConversationRequest 更新对话请求
type UpdateConversationRequest struct {
Title string `json:"title"`
+9 -2
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"net/http"
"strings"
"sync"
"time"
"cyberstrike-ai/internal/multiagent"
@@ -49,6 +50,8 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
var baseCtx context.Context
clientDisconnected := false
// 与 sseKeepalive 共用:禁止并发写 ResponseWriter,否则会破坏 chunked 编码(ERR_INVALID_CHUNKED_ENCODING)。
var sseWriteMu sync.Mutex
sendEvent := func(eventType, message string, data interface{}) {
if clientDisconnected {
return
@@ -66,7 +69,10 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
}
ev := StreamEvent{Type: eventType, Message: message, Data: data}
b, _ := json.Marshal(ev)
if _, err := fmt.Fprintf(c.Writer, "data: %s\n\n", b); err != nil {
sseWriteMu.Lock()
_, err := fmt.Fprintf(c.Writer, "data: %s\n\n", b)
if err != nil {
sseWriteMu.Unlock()
clientDisconnected = true
return
}
@@ -75,6 +81,7 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
} else {
c.Writer.Flush()
}
sseWriteMu.Unlock()
}
h.logger.Info("收到 Eino DeepAgent 流式请求",
@@ -130,7 +137,7 @@ func (h *AgentHandler) MultiAgentLoopStream(c *gin.Context) {
})
stopKeepalive := make(chan struct{})
go sseKeepalive(c, stopKeepalive)
go sseKeepalive(c, stopKeepalive, &sseWriteMu)
defer close(stopKeepalive)
result, runErr := multiagent.RunDeepAgent(
+24 -4
View File
@@ -3,15 +3,27 @@ package handler
import (
"fmt"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
)
// sseKeepalive sends periodic SSE comment lines so proxies (e.g. nginx proxy_read_timeout)
// and idle TCP paths do not close long-running streams when no data events are emitted for a while.
func sseKeepalive(c *gin.Context, stop <-chan struct{}) {
ticker := time.NewTicker(20 * time.Second)
// sseInterval is how often we write on long SSE streams. Shorter intervals help NATs and
// some proxies that treat connections as idle; 10s is a reasonable balance with traffic.
const sseKeepaliveInterval = 10 * time.Second
// sseKeepalive sends periodic SSE traffic so proxies (e.g. nginx proxy_read_timeout), NATs,
// and load balancers do not close long-running streams. Some intermediaries ignore comment-only
// lines, so we send both a comment and a minimal data frame (type heartbeat) per tick.
//
// writeMu must be the same mutex used by sendEvent for this request: concurrent writes to
// http.ResponseWriter break chunked transfer encoding (browser: net::ERR_INVALID_CHUNKED_ENCODING).
func sseKeepalive(c *gin.Context, stop <-chan struct{}, writeMu *sync.Mutex) {
if writeMu == nil {
return
}
ticker := time.NewTicker(sseKeepaliveInterval)
defer ticker.Stop()
for {
select {
@@ -27,12 +39,20 @@ func sseKeepalive(c *gin.Context, stop <-chan struct{}) {
return
default:
}
writeMu.Lock()
if _, err := fmt.Fprintf(c.Writer, ": keepalive\n\n"); err != nil {
writeMu.Unlock()
return
}
// data: frame so strict proxies still see downstream bytes (comments alone may not reset timers)
if _, err := fmt.Fprintf(c.Writer, `data: {"type":"heartbeat"}`+"\n\n"); err != nil {
writeMu.Unlock()
return
}
if flusher, ok := c.Writer.(http.Flusher); ok {
flusher.Flush()
}
writeMu.Unlock()
}
}
}
+271 -229
View File
@@ -101,8 +101,8 @@ func RunDeepAgent(
return
}
progress("tool_result_delta", chunk, map[string]interface{}{
"toolName": toolName,
"toolCallId": toolCallID,
"toolName": toolName,
"toolCallId": toolCallID,
// index/total/iteration are optional for UI; we don't know them in this bridge.
"index": 0,
"total": 0,
@@ -221,7 +221,8 @@ func RunDeepAgent(
Model: subModel,
ToolsConfig: adk.ToolsConfig{
ToolsNodeConfig: compose.ToolsNodeConfig{
Tools: subTools,
Tools: subTools,
UnknownToolsHandler: einomcp.UnknownToolReminderHandler(),
},
EmitInternalEvents: true,
},
@@ -275,7 +276,8 @@ func RunDeepAgent(
},
ToolsConfig: adk.ToolsConfig{
ToolsNodeConfig: compose.ToolsNodeConfig{
Tools: mainTools,
Tools: mainTools,
UnknownToolsHandler: einomcp.UnknownToolReminderHandler(),
},
EmitInternalEvents: true,
},
@@ -284,14 +286,8 @@ func RunDeepAgent(
return nil, fmt.Errorf("deep.New: %w", err)
}
msgs := historyToMessages(history)
msgs = append(msgs, schema.UserMessage(userMessage))
runner := adk.NewRunner(ctx, adk.RunnerConfig{
Agent: da,
EnableStreaming: true,
})
iter := runner.Run(ctx, msgs)
baseMsgs := historyToMessages(history)
baseMsgs = append(baseMsgs, schema.UserMessage(userMessage))
streamsMainAssistant := func(agent string) bool {
return agent == "" || agent == orchestratorName
@@ -303,255 +299,301 @@ func RunDeepAgent(
return "sub"
}
// 仅保留主代理最后一次 assistant 输出,避免把多轮中间回复拼接到最终答案。
var lastRunMsgs []adk.Message
var lastAssistant string
var reasoningStreamSeq int64
var einoSubReplyStreamSeq int64
toolEmitSeen := make(map[string]struct{})
// 主代理「外层轮次」:首次进入编排器为第 1 轮,每从子代理回到编排器 +1。
// 子代理「步数」:该子代理每次发起一批工具调用前 +1(近似 ReAct 步)。
var einoMainRound int
var einoLastAgent string
subAgentToolStep := make(map[string]int)
for {
ev, ok := iter.Next()
if !ok {
break
attemptLoop:
for attempt := 0; attempt < maxToolCallArgumentsJSONAttempts; attempt++ {
msgs := make([]adk.Message, 0, len(baseMsgs)+attempt)
msgs = append(msgs, baseMsgs...)
for i := 0; i < attempt; i++ {
msgs = append(msgs, toolCallArgumentsJSONRetryHint())
}
if ev == nil {
continue
}
if ev.Err != nil {
if attempt > 0 {
mcpIDsMu.Lock()
mcpIDs = mcpIDs[:0]
mcpIDsMu.Unlock()
if logger != nil {
logger.Warn("eino DeepAgent: 工具参数 JSON 被接口拒绝,追加提示后重试",
zap.Int("attempt", attempt),
zap.Int("maxAttempts", maxToolCallArgumentsJSONAttempts))
}
if progress != nil {
progress("error", ev.Err.Error(), map[string]interface{}{
// 使用专用事件类型 eino_recovery,便于前端时间线展示(progress 仅改标题,不进时间线)
progress("eino_recovery", toolCallArgumentsJSONRecoveryTimelineMessage(attempt), map[string]interface{}{
"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 {
streamHeaderSent := false
var reasoningStreamID string
var toolStreamFragments []schema.ToolCall
var subAssistantBuf strings.Builder
var subReplyStreamID string
var mainAssistantBuf strings.Builder
for {
chunk, rerr := mv.MessageStream.Recv()
if rerr != nil {
if errors.Is(rerr, io.EOF) {
break
}
// 仅保留主代理最后一次 assistant 输出;每轮重试重置,避免拼接失败轮次的片段。
lastAssistant = ""
var reasoningStreamSeq int64
var einoSubReplyStreamSeq int64
toolEmitSeen := make(map[string]struct{})
var einoMainRound int
var einoLastAgent string
subAgentToolStep := make(map[string]int)
runner := adk.NewRunner(ctx, adk.RunnerConfig{
Agent: da,
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 {
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 {
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,
if progress != nil {
progress("error", ev.Err.Error(), map[string]interface{}{
"conversationId": conversationID,
"source": "eino",
})
}
if chunk.Content != "" {
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...)
}
return nil, ev.Err
}
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{}{
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,
"einoAgent": ev.AgentName,
"einoRole": "sub",
"source": "eino",
"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",
})
}
}
}
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{}{
einoLastAgent = ev.AgentName
progress("progress", fmt.Sprintf("[Eino] %s", ev.AgentName), map[string]interface{}{
"conversationId": conversationID,
"source": "eino",
"einoAgent": ev.AgentName,
"einoRole": einoRoleTag(ev.AgentName),
})
}
body := strings.TrimSpace(msg.Content)
if body != "" {
if streamsMainAssistant(ev.AgentName) {
if progress != nil {
progress("response_start", "", map[string]interface{}{
"conversationId": conversationID,
"mcpExecutionIds": snapshotMCPIDs(),
"messageGeneratedBy": "eino:" + ev.AgentName,
"einoRole": "orchestrator",
})
progress("response_delta", body, map[string]interface{}{
"conversationId": conversationID,
"mcpExecutionIds": snapshotMCPIDs(),
"einoRole": "orchestrator",
if ev.Output == nil || ev.Output.MessageOutput == nil {
continue
}
mv := ev.Output.MessageOutput
if mv.IsStreaming && mv.MessageStream != nil {
streamHeaderSent := false
var reasoningStreamID string
var toolStreamFragments []schema.ToolCall
var subAssistantBuf strings.Builder
var subReplyStreamID string
var mainAssistantBuf strings.Builder
for {
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
} else if progress != nil {
progress("eino_agent_reply", body, map[string]interface{}{
if chunk.Content != "" {
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 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,
"einoAgent": ev.AgentName,
"einoRole": "sub",
"source": "eino",
"einoAgent": ev.AgentName,
"einoRole": einoRoleTag(ev.AgentName),
})
}
}
}
if mv.Role == schema.Tool && progress != nil {
toolName := msg.ToolName
if toolName == "" {
toolName = mv.ToolName
body := strings.TrimSpace(msg.Content)
if body != "" {
if streamsMainAssistant(ev.AgentName) {
if progress != nil {
progress("response_start", "", map[string]interface{}{
"conversationId": conversationID,
"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,避免前端误判为成功。
content := msg.Content
isErr := false
if strings.HasPrefix(content, einomcp.ToolErrorPrefix) {
isErr = true
content = strings.TrimPrefix(content, einomcp.ToolErrorPrefix)
}
if mv.Role == schema.Tool && progress != nil {
toolName := msg.ToolName
if toolName == "" {
toolName = mv.ToolName
}
preview := content
if len(preview) > 200 {
preview = preview[:200] + "..."
// bridge 工具在 res.IsError=true 时会返回带前缀的内容;这里解析为 success/isError,避免前端误判为成功。
content := msg.Content
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...)
mcpIDsMu.Unlock()
histJSON, _ := json.Marshal(msgs)
histJSON, _ := json.Marshal(lastRunMsgs)
cleaned := strings.TrimSpace(lastAssistant)
cleaned = dedupeRepeatedParagraphs(cleaned, 80)
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")
}
}
+54
View File
@@ -1803,6 +1803,16 @@ header {
color: var(--text-primary);
}
.chat-file-chip--uploading {
opacity: 0.92;
border-style: dashed;
}
.chat-file-chip--error {
border-color: rgba(220, 38, 38, 0.45);
background: rgba(220, 38, 38, 0.06);
}
.chat-file-input-hidden {
position: absolute;
width: 0;
@@ -14622,3 +14632,47 @@ button.chat-files-dropdown-item:hover:not(:disabled) {
transform: translateX(-50%) translateY(0);
}
/* 对话附件读取 / 文件管理上传 进度条 */
/* [hidden] 默认会被本类的 display:flex 覆盖,须显式隐藏否则空闲时仍露出灰条 */
.chat-upload-progress-row[hidden] {
display: none !important;
}
.chat-upload-progress-row {
display: flex;
flex-direction: column;
gap: 6px;
margin: 8px 0 4px;
padding: 8px 10px;
border-radius: 8px;
background: var(--bg-secondary, rgba(0, 0, 0, 0.04));
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.08));
}
.chat-upload-progress-row--files {
margin-top: 12px;
margin-bottom: 0;
}
.chat-upload-progress-track {
height: 6px;
border-radius: 4px;
background: var(--border-color, rgba(0, 0, 0, 0.1));
overflow: hidden;
}
.chat-upload-progress-fill {
height: 100%;
width: 0%;
border-radius: 4px;
background: var(--accent-primary, #2563eb);
transition: width 0.12s ease-out;
}
.chat-upload-progress-label {
font-size: 0.8rem;
color: var(--text-secondary, #666);
line-height: 1.35;
word-break: break-all;
}
+9
View File
@@ -123,6 +123,13 @@
"inputPlaceholder": "Enter target or command... (type @ to select tools | Shift+Enter newline, Enter send)",
"selectFile": "Select file",
"uploadFile": "Upload file (multi-select or drag & drop)",
"readingAttachmentsDetail": "Reading attachment {{current}}/{{total}} · {{name}} · {{percent}}%",
"uploadingAttachmentsDetail": "Uploading attachments · {{done}}/{{total}} done · {{percent}}% overall",
"waitingAttachmentsUpload": "Waiting for attachments to finish uploading…",
"attachmentsUploadIncomplete": "Some attachments failed to upload. Remove the failed items or pick files again before sending.",
"attachmentUploading": "Uploading…",
"attachmentUploadFailed": "Failed",
"attachmentUploadAlert": "Upload failed: {{name}}",
"send": "Send",
"searchInGroup": "Search in group...",
"loadingTools": "Loading tools...",
@@ -162,6 +169,7 @@
"taskCancelled": "Task cancelled",
"unknownTool": "Unknown tool",
"einoAgentReplyTitle": "Sub-agent reply",
"einoRecoveryTitle": "🔄 Invalid tool JSON · run {{n}}/{{max}} (hint appended)",
"noDescription": "No description",
"noResponseData": "No response data",
"loading": "Loading...",
@@ -1124,6 +1132,7 @@
"copyPathTitle": "Copy the absolute path on the server; paste into chat to reference this file",
"pathCopied": "Path copied — paste it into chat",
"uploadOkHint": "Uploaded. Use “Copy path” to copy the absolute path.",
"uploadingFile": "Uploading {{name}} · {{percent}}%",
"moreActions": "More: open chat, edit, rename, delete",
"download": "Download",
"edit": "Edit",
+9
View File
@@ -123,6 +123,13 @@
"inputPlaceholder": "输入测试目标或命令... (输入 @ 选择工具 | Shift+Enter 换行,Enter 发送)",
"selectFile": "选择文件",
"uploadFile": "上传文件(可多选或拖拽到此处)",
"readingAttachmentsDetail": "读取附件 {{current}}/{{total}} · {{name}} · {{percent}}%",
"uploadingAttachmentsDetail": "上传附件 · {{done}}/{{total}} 已完成 · 总进度 {{percent}}%",
"waitingAttachmentsUpload": "正在等待附件上传完成…",
"attachmentsUploadIncomplete": "部分附件未上传成功,请移除失败项或重新选择文件后再发送。",
"attachmentUploading": "上传中…",
"attachmentUploadFailed": "失败",
"attachmentUploadAlert": "上传失败:{{name}}",
"send": "发送",
"searchInGroup": "搜索分组中的对话...",
"loadingTools": "正在加载工具...",
@@ -162,6 +169,7 @@
"taskCancelled": "任务已取消",
"unknownTool": "未知工具",
"einoAgentReplyTitle": "子代理回复",
"einoRecoveryTitle": "🔄 工具参数无效 · 第 {{n}}/{{max}} 轮(已追加提示)",
"noDescription": "暂无描述",
"noResponseData": "暂无响应数据",
"loading": "加载中...",
@@ -1124,6 +1132,7 @@
"copyPathTitle": "复制服务器上的绝对路径,可粘贴到对话中让模型引用该文件",
"pathCopied": "路径已复制,可到对话中粘贴使用",
"uploadOkHint": "上传成功。点击「复制路径」可复制绝对路径到剪贴板。",
"uploadingFile": "正在上传 {{name}} · {{percent}}%",
"moreActions": "更多:打开对话、编辑、重命名、删除",
"download": "下载",
"edit": "编辑",
+48
View File
@@ -163,6 +163,54 @@ async function apiFetch(url, options = {}) {
return response;
}
/**
* multipart POST with XMLHttpRequest so upload progress is available (fetch 无法可靠上报进度).
* 返回与 fetch 类似的对象okstatusjson()text()
*/
async function apiUploadWithProgress(url, formData, options = {}) {
await ensureAuthenticated();
const onProgress = typeof options.onProgress === 'function' ? options.onProgress : null;
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', url);
if (authToken) {
xhr.setRequestHeader('Authorization', `Bearer ${authToken}`);
}
xhr.upload.onprogress = (e) => {
if (!onProgress || !e.lengthComputable) return;
const percent = e.total > 0 ? Math.round((e.loaded / e.total) * 100) : 0;
onProgress({ loaded: e.loaded, total: e.total, percent });
};
xhr.onerror = () => {
reject(new Error('Network error'));
};
xhr.onload = () => {
if (xhr.status === 401) {
handleUnauthorized();
const msg = (typeof window !== 'undefined' && typeof window.t === 'function')
? window.t('auth.unauthorized')
: '未授权访问';
reject(new Error(msg));
return;
}
const responseText = xhr.responseText || '';
resolve({
ok: xhr.status >= 200 && xhr.status < 300,
status: xhr.status,
text: async () => responseText,
json: async () => {
try {
return responseText ? JSON.parse(responseText) : {};
} catch (err) {
throw err;
}
},
});
};
xhr.send(formData);
});
}
async function submitLogin(event) {
event.preventDefault();
const passwordInput = document.getElementById('login-password');
+49 -4
View File
@@ -12,6 +12,8 @@ const CHAT_FILES_BROWSE_PATH_KEY = 'csai_chat_files_browse_path';
let chatFilesBrowsePath = [];
/** 非空时,下一次上传文件落到此相对路径(chat_uploads 下目录),如 2026-03-21/uuid/sub */
let chatFilesPendingUploadDir = '';
/** 文件管理页面向服务器上传进行中,避免重复选择并禁用顶栏按钮 */
let chatFilesXHRUploadBusy = false;
/** 仅前端记录的「空目录」键 parentPath'' 表示 chat_uploads 根)-> 子目录名列表,与树合并以便 mkdir 后可见 */
const CHAT_FILES_SYNTHETIC_DIRS_KEY = 'csai_chat_files_synthetic_dirs';
@@ -301,7 +303,7 @@ function chatFilesNameFilter(files) {
/** 仅前端按文件名筛选,不重新请求 */
function chatFilesFilterNameOnInput() {
if (!chatFilesCache.length) return;
if (!chatFilesCache.length && chatFilesGetGroupByMode() !== 'folder') return;
renderChatFilesTable();
}
@@ -554,8 +556,10 @@ function renderChatFilesTable() {
if (!wrap) return;
chatFilesDisplayed = chatFilesNameFilter(chatFilesCache);
const groupMode = chatFilesGetGroupByMode();
const emptyMsg = (typeof window.t === 'function') ? window.t('chatFilesPage.empty') : '暂无文件';
if (!chatFilesDisplayed.length) {
// 「按文件夹」模式下即使尚无文件,也要显示 chat_uploads 路径栏与「新建文件夹」,否则无法先建目录
if (!chatFilesDisplayed.length && groupMode !== 'folder') {
wrap.classList.remove('chat-files-table-wrap--grouped');
wrap.classList.remove('chat-files-table-wrap--tree');
wrap.innerHTML = '<div class="empty-state" data-i18n="chatFilesPage.empty">' + escapeHtml(emptyMsg) + '</div>';
@@ -665,7 +669,6 @@ function renderChatFilesTable() {
<th>${escapeHtml(thActions)}</th>
</tr></thead>`;
const groupMode = chatFilesGetGroupByMode();
let innerHtml;
if (groupMode === 'folder') {
@@ -1197,7 +1200,36 @@ async function submitChatFilesMkdir() {
}
}
function chatFilesSetUploadProgressUI(visible, percent, fileName) {
const wrap = document.getElementById('chat-files-upload-progress');
const fill = document.getElementById('chat-files-upload-progress-fill');
const label = document.getElementById('chat-files-upload-progress-label');
if (!wrap || !fill || !label) return;
if (!visible) {
wrap.hidden = true;
fill.style.width = '0%';
label.textContent = '';
return;
}
wrap.hidden = false;
const p = Math.min(100, Math.max(0, Math.round(percent)));
fill.style.width = p + '%';
const name = fileName || '';
label.textContent = (typeof window.t === 'function')
? window.t('chatFilesPage.uploadingFile', { name: name, percent: p })
: ('正在上传 ' + name + ' · ' + p + '%');
}
function chatFilesSetUploadBusy(busy) {
chatFilesXHRUploadBusy = !!busy;
['chat-files-header-upload-btn', 'chat-files-refresh-btn'].forEach(function (id) {
const el = document.getElementById(id);
if (el) el.disabled = chatFilesXHRUploadBusy;
});
}
function chatFilesOpenUploadPicker() {
if (chatFilesXHRUploadBusy) return;
if (chatFilesGetGroupByMode() === 'folder') {
chatFilesPendingUploadDir = chatFilesBrowsePath.join('/');
} else {
@@ -1209,6 +1241,7 @@ function chatFilesOpenUploadPicker() {
function chatFilesUploadToFolderClick(ev, btn) {
if (ev) ev.stopPropagation();
if (chatFilesXHRUploadBusy) return;
const raw = btn.getAttribute('data-upload-dir');
if (!raw) return;
try {
@@ -1237,12 +1270,22 @@ async function onChatFilesUploadPick(ev) {
form.append('conversationId', conv.value.trim());
}
}
chatFilesSetUploadBusy(true);
chatFilesSetUploadProgressUI(true, 0, file.name);
try {
const res = await apiFetch('/api/chat-uploads', { method: 'POST', body: form });
const doXhr = typeof apiUploadWithProgress === 'function';
const res = doXhr
? await apiUploadWithProgress('/api/chat-uploads', form, {
onProgress: function (p) {
chatFilesSetUploadProgressUI(true, p.percent, file.name);
}
})
: await apiFetch('/api/chat-uploads', { method: 'POST', body: form });
if (!res.ok) {
throw new Error(await res.text());
}
const data = await res.json().catch(() => ({}));
chatFilesSetUploadProgressUI(true, 100, file.name);
loadChatFilesPage();
if (data && data.ok) {
const msg = (typeof window.t === 'function')
@@ -1253,6 +1296,8 @@ async function onChatFilesUploadPick(ev) {
} catch (e) {
alert((e && e.message) ? e.message : String(e));
} finally {
chatFilesSetUploadBusy(false);
chatFilesSetUploadProgressUI(false);
input.value = '';
}
}
+232 -56
View File
@@ -25,8 +25,12 @@ const DRAFT_SAVE_DELAY = 500; // 500ms防抖延迟
// 对话文件上传相关(后端会拼接路径与内容发给大模型,前端不再重复发文件列表)
const MAX_CHAT_FILES = 10;
const CHAT_FILE_DEFAULT_PROMPT = '请根据上传的文件内容进行分析。';
/** @type {{ fileName: string, content: string, mimeType: string }[]} */
/**
* 对话附件选文件后异步 POST /api/chat-uploads发送时只传 serverPath绝对路径请求体不再内联大文件内容
* @type {{ id: number, fileName: string, mimeType: string, serverPath: string|null, uploading: boolean, uploadPercent: number, uploadPromise: Promise<void>|null, uploadError: string|null }[]}
*/
let chatAttachments = [];
let chatAttachmentSeq = 0;
// 多代理(Eino):需后端 multi_agent.enabled,与单代理 /agent-loop 并存
const AGENT_MODE_STORAGE_KEY = 'cyberstrike-chat-agent-mode';
@@ -236,6 +240,30 @@ async function sendMessage() {
if (!message && !hasAttachments) {
return;
}
if (hasAttachments) {
const needWait = chatAttachments.some((a) => a.uploading);
if (needWait) {
const waitLabel = (typeof window.t === 'function')
? window.t('chat.waitingAttachmentsUpload')
: '正在等待附件上传完成…';
chatAttachmentProgressSet(true, 0, waitLabel);
}
try {
await Promise.all(chatAttachments.map((a) => (a.uploadPromise ? a.uploadPromise : Promise.resolve())));
} finally {
refreshChatAttachmentUploadProgress();
}
const bad = chatAttachments.filter((a) => !a.serverPath);
if (bad.length) {
const hint = (typeof window.t === 'function')
? window.t('chat.attachmentsUploadIncomplete')
: '部分附件未上传成功,请移除失败项或重新选择文件后再发送。';
alert(hint);
return;
}
}
// 有附件且用户未输入时,发一句简短默认提示即可(后端会拼接路径和文件内容给大模型)
if (hasAttachments && !message) {
message = CHAT_FILE_DEFAULT_PROMPT;
@@ -274,10 +302,10 @@ async function sendMessage() {
role: typeof getCurrentRole === 'function' ? getCurrentRole() : ''
};
if (hasAttachments) {
body.attachments = chatAttachments.map(a => ({
body.attachments = chatAttachments.map((a) => ({
fileName: a.fileName,
content: a.content,
mimeType: a.mimeType || ''
mimeType: a.mimeType || '',
serverPath: a.serverPath
}));
}
// 发送后清空附件列表
@@ -386,11 +414,19 @@ function renderChatFileChips() {
chatAttachments.forEach((a, i) => {
const chip = document.createElement('div');
chip.className = 'chat-file-chip';
if (a.uploading) chip.classList.add('chat-file-chip--uploading');
if (a.uploadError) chip.classList.add('chat-file-chip--error');
chip.setAttribute('role', 'listitem');
const name = document.createElement('span');
name.className = 'chat-file-chip-name';
name.title = a.fileName;
name.textContent = a.fileName;
let label = a.fileName;
if (a.uploading) {
label += ' · ' + ((typeof window.t === 'function') ? window.t('chat.attachmentUploading') : '上传中…');
} else if (a.uploadError) {
label += ' · ' + ((typeof window.t === 'function') ? window.t('chat.attachmentUploadFailed') : '失败');
}
name.textContent = label;
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'chat-file-chip-remove';
@@ -407,6 +443,7 @@ function renderChatFileChips() {
function removeChatAttachment(index) {
chatAttachments.splice(index, 1);
renderChatFileChips();
refreshChatAttachmentUploadProgress();
}
// 有附件且输入框为空时,填入一句默认提示(可编辑);后端会单独拼接路径与内容给大模型
@@ -419,46 +456,122 @@ function appendChatFilePrompt() {
}
}
function readFileAsAttachment(file) {
return new Promise((resolve, reject) => {
const mimeType = file.type || '';
const isTextLike = /^text\//i.test(mimeType) || /^(application\/(json|xml|javascript)|image\/svg\+xml)/i.test(mimeType);
const reader = new FileReader();
reader.onload = () => {
let content = reader.result;
if (typeof content === 'string' && content.startsWith('data:')) {
content = content.replace(/^data:[^;]+;base64,/, '');
}
resolve({ fileName: file.name, content: content, mimeType: mimeType });
};
reader.onerror = () => reject(reader.error);
if (isTextLike) {
reader.readAsText(file, 'UTF-8');
} else {
reader.readAsDataURL(file);
}
});
function chatAttachmentProgressSet(visible, percent, detailText) {
const wrap = document.getElementById('chat-attachment-progress');
const fill = document.getElementById('chat-attachment-progress-fill');
const label = document.getElementById('chat-attachment-progress-label');
if (!wrap || !fill || !label) return;
if (!visible) {
wrap.hidden = true;
fill.style.width = '0%';
label.textContent = '';
return;
}
wrap.hidden = false;
const p = Math.min(100, Math.max(0, Math.round(percent)));
fill.style.width = p + '%';
label.textContent = detailText || '';
}
function addFilesToChat(files) {
function refreshChatAttachmentUploadProgress() {
if (!chatAttachments.length) {
chatAttachmentProgressSet(false);
return;
}
const uploading = chatAttachments.filter((a) => a.uploading);
if (!uploading.length) {
chatAttachmentProgressSet(false);
return;
}
let sum = 0;
chatAttachments.forEach((a) => {
sum += a.uploading ? (a.uploadPercent || 0) : 100;
});
const overall = Math.round(sum / chatAttachments.length);
const line = (typeof window.t === 'function')
? window.t('chat.uploadingAttachmentsDetail', {
done: chatAttachments.length - uploading.length,
total: chatAttachments.length,
percent: overall
})
: ('上传附件 ' + (chatAttachments.length - uploading.length) + '/' + chatAttachments.length + ' · ' + overall + '%');
chatAttachmentProgressSet(true, overall, line);
}
async function uploadOneChatAttachment(entry, file) {
const form = new FormData();
form.append('file', file);
const conv = currentConversationId;
if (conv && String(conv).trim()) {
form.append('conversationId', String(conv).trim());
}
const entryId = entry.id;
try {
const res = typeof apiUploadWithProgress === 'function'
? await apiUploadWithProgress('/api/chat-uploads', form, {
onProgress: function (p) {
const cur = chatAttachments.find((x) => x.id === entryId);
if (cur) {
cur.uploadPercent = p.percent;
refreshChatAttachmentUploadProgress();
}
}
})
: await apiFetch('/api/chat-uploads', { method: 'POST', body: form });
if (!res.ok) {
throw new Error(await res.text());
}
const data = await res.json().catch(() => ({}));
const abs = data.absolutePath ? String(data.absolutePath).trim() : '';
if (!abs) {
throw new Error('no absolutePath in response');
}
const cur = chatAttachments.find((x) => x.id === entryId);
if (cur) {
cur.serverPath = abs;
cur.uploading = false;
cur.uploadPercent = 100;
cur.uploadError = null;
}
} catch (e) {
const msg = (e && e.message) ? e.message : String(e);
const cur = chatAttachments.find((x) => x.id === entryId);
if (cur) {
cur.uploading = false;
cur.uploadError = msg;
cur.serverPath = null;
}
alert(((typeof window.t === 'function') ? window.t('chat.attachmentUploadAlert', { name: file.name }) : ('上传失败:' + file.name)) + '\n' + msg);
}
renderChatFileChips();
refreshChatAttachmentUploadProgress();
}
async function addFilesToChat(files) {
if (!files || !files.length) return;
const next = Array.from(files);
if (chatAttachments.length + next.length > MAX_CHAT_FILES) {
alert('最多同时上传 ' + MAX_CHAT_FILES + ' 个文件,当前已选 ' + chatAttachments.length + ' 个。');
return;
}
const addOne = (file) => {
return readFileAsAttachment(file).then((a) => {
chatAttachments.push(a);
renderChatFileChips();
appendChatFilePrompt();
}).catch(() => {
alert('读取文件失败:' + file.name);
});
};
let p = Promise.resolve();
next.forEach((file) => { p = p.then(() => addOne(file)); });
p.then(() => {});
next.forEach((file) => {
const id = ++chatAttachmentSeq;
const entry = {
id: id,
fileName: file.name,
mimeType: file.type || '',
serverPath: null,
uploading: true,
uploadPercent: 0,
uploadPromise: null,
uploadError: null
};
entry.uploadPromise = uploadOneChatAttachment(entry, file);
chatAttachments.push(entry);
});
renderChatFileChips();
refreshChatAttachmentUploadProgress();
appendChatFilePrompt();
}
function setupChatFileUpload() {
@@ -469,7 +582,7 @@ function setupChatFileUpload() {
inputEl.addEventListener('change', function () {
const files = this.files;
if (files && files.length) {
addFilesToChat(files);
addFilesToChat(files).catch(function () { /* addFilesToChat 已提示 */ });
}
this.value = '';
});
@@ -491,7 +604,7 @@ function setupChatFileUpload() {
e.stopPropagation();
this.classList.remove('drag-over');
const files = e.dataTransfer && e.dataTransfer.files;
if (files && files.length) addFilesToChat(files);
if (files && files.length) addFilesToChat(files).catch(function () { /* addFilesToChat 已提示 */ });
});
}
@@ -1406,7 +1519,50 @@ function copyMessageToClipboard(messageDiv, button) {
try {
// 获取保存的原始Markdown内容
const originalContent = messageDiv.dataset.originalContent;
// 统一的复制处理函数
const doCopy = (text) => {
// 优先使用现代 Clipboard API(需要 HTTPS 或 localhost
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(text).then(() => {
showCopySuccess(button);
}).catch(err => {
console.error('Clipboard API 复制失败:', err);
fallbackCopy(text);
});
} else {
// 降级方案:使用传统的 execCommand 方法(适用于 HTTP 环境)
return fallbackCopy(text);
}
};
// 降级复制函数(使用 document.execCommand
const fallbackCopy = (text) => {
try {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
textArea.style.opacity = '0';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
const successful = document.execCommand('copy');
document.body.removeChild(textArea);
if (successful) {
showCopySuccess(button);
} else {
throw new Error('execCommand copy failed');
}
} catch (execErr) {
console.error('降级复制失败:', execErr);
alert(typeof window.t === 'function' ? window.t('chat.copyFailedManual') : '复制失败,请手动选择内容复制');
}
};
if (!originalContent) {
// 如果没有保存原始内容,尝试从渲染后的HTML提取(降级方案)
const bubble = messageDiv.querySelector('.message-bubble');
@@ -1423,24 +1579,14 @@ function copyMessageToClipboard(messageDiv, button) {
// 提取纯文本内容
let textContent = tempDiv.textContent || tempDiv.innerText || '';
textContent = textContent.replace(/\n{3,}/g, '\n\n').trim();
navigator.clipboard.writeText(textContent).then(() => {
showCopySuccess(button);
}).catch(err => {
console.error('复制失败:', err);
alert(typeof window.t === 'function' ? window.t('chat.copyFailedManual') : '复制失败,请手动选择内容复制');
});
doCopy(textContent);
}
return;
}
// 使用原始Markdown内容
navigator.clipboard.writeText(originalContent).then(() => {
showCopySuccess(button);
}).catch(err => {
console.error('复制失败:', err);
alert(typeof window.t === 'function' ? window.t('chat.copyFailedManual') : '复制失败,请手动选择内容复制');
});
doCopy(originalContent);
} catch (error) {
console.error('复制消息时出错:', error);
alert(typeof window.t === 'function' ? window.t('chat.copyFailedManual') : '复制失败,请手动选择内容复制');
@@ -1549,6 +1695,20 @@ function renderProcessDetails(messageId, processDetails) {
detailsContainer.appendChild(contentDiv);
}
// processDetails === null 表示“尚未加载(懒加载)”
const isLazyNotLoaded = (processDetails === null);
if (isLazyNotLoaded) {
detailsContainer.dataset.lazyNotLoaded = '1';
detailsContainer.dataset.loaded = '0';
timeline.innerHTML = '<div class="progress-timeline-empty">' +
(typeof window.t === 'function' ? window.t('chat.expandDetail') : '展开详情') +
'(点击后加载)</div>';
// 默认折叠
timeline.classList.remove('expanded');
return;
}
detailsContainer.dataset.lazyNotLoaded = '0';
detailsContainer.dataset.loaded = '1';
// 如果没有processDetails或为空,显示空状态
if (!processDetails || processDetails.length === 0) {
// 显示空状态提示
@@ -1581,6 +1741,9 @@ function renderProcessDetails(messageId, processDetails) {
itemTitle = agPx + (typeof window.t === 'function' ? window.t('chat.iterationRound', { n: data.iteration || 1 }) : '第 ' + (data.iteration || 1) + ' 轮迭代');
} else if (eventType === 'thinking') {
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') {
itemTitle = agPx + '🔧 ' + (typeof window.t === 'function' ? window.t('chat.toolCallsDetected', { count: data.count || 0 }) : '检测到 ' + (data.count || 0) + ' 个工具调用');
} else if (eventType === 'tool_call') {
@@ -1600,6 +1763,10 @@ function renderProcessDetails(messageId, processDetails) {
itemTitle = agPx + execLine;
} else if (eventType === 'eino_agent_reply') {
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') {
itemTitle = '📚 ' + (typeof window.t === 'function' ? window.t('chat.knowledgeRetrieval') : '知识检索');
} else if (eventType === 'error') {
@@ -2181,7 +2348,8 @@ function getConversationGroup(dateObj, todayStart, startOfWeek, yesterdayStart)
// 加载对话
async function loadConversation(conversationId) {
try {
const response = await apiFetch(`/api/conversations/${conversationId}`);
// 轻量加载:不带 processDetails,避免历史会话切换卡顿;展开详情时再按需拉取
const response = await apiFetch(`/api/conversations/${conversationId}?include_process_details=0`);
const conversation = await response.json();
if (!response.ok) {
@@ -2280,11 +2448,18 @@ async function loadConversation(conversationId) {
// 传递消息的创建时间
const messageId = addMessage(msg.role, displayContent, msg.mcpExecutionIds || [], null, msg.createdAt);
// 绑定后端 messageId,供按需加载过程详情使用
const messageEl = document.getElementById(messageId);
if (messageEl && msg && msg.id) {
messageEl.dataset.backendMessageId = String(msg.id);
}
// 对于助手消息,总是渲染过程详情(即使没有processDetails也要显示展开详情按钮)
if (msg.role === 'assistant') {
// 延迟一下,确保消息已经渲染
setTimeout(() => {
renderProcessDetails(messageId, msg.processDetails || []);
// 如果后端未返回 processDetails 字段,传 null 表示“尚未加载,点击展开时再请求”
const hasField = msg && Object.prototype.hasOwnProperty.call(msg, 'processDetails');
renderProcessDetails(messageId, hasField ? (msg.processDetails || []) : null);
// 如果有过程详情,检查是否有错误或取消事件,如果有,确保详情默认折叠
if (msg.processDetails && msg.processDetails.length > 0) {
const hasErrorOrCancelled = msg.processDetails.some(d =>
@@ -5361,7 +5536,8 @@ async function downloadConversationMarkdownFromContext(includeToolDetails = fals
if (!convId) return;
try {
const response = await apiFetch(`/api/conversations/${convId}`);
// 下载不影响页面性能:直接从后端一次性拉取全量过程详情
const response = await apiFetch(`/api/conversations/${convId}?include_process_details=1`);
let conversation = null;
try {
conversation = await response.json();
+82 -3
View File
@@ -506,6 +506,46 @@ function toggleProcessDetails(progressId, assistantMessageId) {
const detailsId = 'process-details-' + assistantMessageId;
const detailsContainer = document.getElementById(detailsId);
if (!detailsContainer) return;
// 懒加载:首次展开时才从后端拉取该条消息的过程详情
const maybeLazy = detailsContainer.dataset && detailsContainer.dataset.lazyNotLoaded === '1' && detailsContainer.dataset.loaded !== '1';
if (maybeLazy) {
const messageEl = document.getElementById(assistantMessageId);
const backendMessageId = messageEl && messageEl.dataset ? messageEl.dataset.backendMessageId : '';
if (backendMessageId && typeof apiFetch === 'function' && typeof renderProcessDetails === 'function') {
if (detailsContainer.dataset.loading === '1') {
// 正在加载中,避免重复请求
} else {
detailsContainer.dataset.loading = '1';
// 先展开容器,显示加载态
const timeline = detailsContainer.querySelector('.progress-timeline');
if (timeline) {
timeline.innerHTML = '<div class="progress-timeline-empty">' + ((typeof window.t === 'function') ? window.t('common.loading') : '加载中…') + '</div>';
}
apiFetch(`/api/messages/${encodeURIComponent(String(backendMessageId))}/process-details`)
.then(async (res) => {
const j = await res.json().catch(() => ({}));
if (!res.ok) throw new Error((j && j.error) ? j.error : res.status);
const details = (j && Array.isArray(j.processDetails)) ? j.processDetails : [];
// 重新渲染详情(renderProcessDetails 会清掉 lazy 标记并写入 loaded
renderProcessDetails(assistantMessageId, details);
})
.catch((e) => {
console.error('加载过程详情失败:', e);
const tl = detailsContainer.querySelector('.progress-timeline');
if (tl) {
tl.innerHTML = '<div class="progress-timeline-empty">' + ((typeof window.t === 'function') ? window.t('chat.noProcessDetail') : '暂无过程详情(加载失败)') + '</div>';
}
// 失败时保留 lazy 状态,允许用户重试
detailsContainer.dataset.lazyNotLoaded = '1';
detailsContainer.dataset.loaded = '0';
})
.finally(() => {
detailsContainer.dataset.loading = '0';
});
}
}
}
const content = detailsContainer.querySelector('.process-details-content');
const timeline = detailsContainer.querySelector('.progress-timeline');
@@ -691,6 +731,9 @@ function handleStreamEvent(event, progressElement, progressId,
};
switch (event.type) {
case 'heartbeat':
// SSE 长连接保活,无需更新 UI
break;
case 'conversation':
if (event.data && event.data.conversationId) {
// 在更新之前,先获取任务对应的原始对话ID
@@ -846,7 +889,22 @@ function handleStreamEvent(event, progressElement, progressId,
data: event.data
});
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':
const toolInfo = event.data || {};
const toolName = toolInfo.toolName || (typeof window.t === 'function' ? window.t('chat.unknownTool') : '未知工具');
@@ -1420,6 +1478,15 @@ function addTimelineItem(timeline, type, options) {
if (type === 'progress' && 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) {
item.dataset.toolCallsCount = String(options.data.count);
}
@@ -1473,7 +1540,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>`;
} else if (type === 'tool_call' && options.data) {
const data = options.data;
@@ -1518,6 +1585,12 @@ function addTimelineItem(timeline, type, options) {
</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') {
const taskCancelledLabel = typeof window.t === 'function' ? window.t('chat.taskCancelled') : '任务已取消';
content += `
@@ -1526,7 +1599,7 @@ function addTimelineItem(timeline, type, options) {
</div>
`;
}
item.innerHTML = content;
if (options.data) {
applyEinoTimelineRole(item, options.data);
@@ -2347,6 +2420,8 @@ function refreshProgressAndTimelineI18n() {
}
} else if (type === 'thinking') {
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) {
const count = parseInt(item.dataset.toolCallsCount, 10) || 0;
titleSpan.textContent = ap + '\uD83D\uDD27 ' + _t('chat.toolCallsDetected', { count: count });
@@ -2362,6 +2437,10 @@ function refreshProgressAndTimelineI18n() {
titleSpan.textContent = ap + icon + (success ? _t('chat.toolExecComplete', { name: name }) : _t('chat.toolExecFailed', { name: name }));
} else if (type === 'eino_agent_reply') {
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') {
titleSpan.textContent = '\u26D4 ' + _t('chat.taskCancelled');
} else if (type === 'progress' && item.dataset.progressMessage !== undefined) {
+11 -3
View File
@@ -626,6 +626,10 @@
</div>
<div class="chat-input-with-files">
<div id="chat-file-list" class="chat-file-list" aria-label="已选文件列表"></div>
<div id="chat-attachment-progress" class="chat-upload-progress-row" hidden role="status" aria-live="polite">
<div class="chat-upload-progress-track" aria-hidden="true"><div class="chat-upload-progress-fill" id="chat-attachment-progress-fill"></div></div>
<span class="chat-upload-progress-label" id="chat-attachment-progress-label"></span>
</div>
<div class="chat-input-field">
<textarea id="chat-input" data-i18n="chat.inputPlaceholder" data-i18n-attr="placeholder" data-i18n-skip-text="true" placeholder="输入测试目标或命令... (输入 @ 选择工具 | Shift+Enter 换行,Enter 发送)" rows="1"></textarea>
<div id="mention-suggestions" class="mention-suggestions" role="listbox" aria-label="工具提及候选"></div>
@@ -637,7 +641,7 @@
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<button class="send-btn" onclick="sendMessage()">
<button type="button" class="send-btn" id="chat-send-btn" onclick="sendMessage()">
<span data-i18n="chat.send">发送</span>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 12h14M12 5l7 7-7 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
@@ -1074,9 +1078,9 @@
<div class="page-header">
<h2 data-i18n="chatFilesPage.title">文件管理</h2>
<div class="page-header-actions">
<button type="button" class="btn-primary" onclick="chatFilesOpenUploadPicker()" data-i18n="chatFilesPage.upload">上传文件</button>
<button type="button" class="btn-primary" id="chat-files-header-upload-btn" onclick="chatFilesOpenUploadPicker()" data-i18n="chatFilesPage.upload">上传文件</button>
<input type="file" id="chat-files-upload-input" style="display:none" onchange="onChatFilesUploadPick(event)" />
<button class="btn-secondary" onclick="loadChatFilesPage()" data-i18n="common.refresh">刷新</button>
<button type="button" class="btn-secondary" id="chat-files-refresh-btn" onclick="loadChatFilesPage()" data-i18n="common.refresh">刷新</button>
</div>
</div>
<div class="page-content">
@@ -1101,6 +1105,10 @@
</label>
<button class="btn-secondary" type="button" onclick="loadChatFilesPage()" data-i18n="common.search">搜索</button>
</div>
<div id="chat-files-upload-progress" class="chat-upload-progress-row chat-upload-progress-row--files" hidden role="status" aria-live="polite">
<div class="chat-upload-progress-track" aria-hidden="true"><div class="chat-upload-progress-fill" id="chat-files-upload-progress-fill"></div></div>
<span class="chat-upload-progress-label" id="chat-files-upload-progress-label"></span>
</div>
<div id="chat-files-list-wrap" class="chat-files-table-wrap">
<div class="loading-spinner" data-i18n="common.loading">加载中…</div>
</div>