Compare commits

...
25 Commits
Author SHA1 Message Date
公明andGitHub dbc4ead040 Update config.example.yaml 2026-07-22 21:09:53 +08:00
公明andGitHub 4333cf1dd3 Add files via upload 2026-07-22 21:07:21 +08:00
公明andGitHub 577c97aab0 Add files via upload 2026-07-22 21:04:18 +08:00
公明andGitHub 93ab362b6f Add files via upload 2026-07-22 21:01:56 +08:00
公明andGitHub d9cb0b11c5 Add files via upload 2026-07-22 17:34:24 +08:00
公明andGitHub 2d7f5322b3 Add files via upload 2026-07-22 16:29:24 +08:00
公明andGitHub 3763da8773 Add files via upload 2026-07-22 16:23:57 +08:00
公明andGitHub 3f2984b7c6 Add files via upload 2026-07-22 16:18:33 +08:00
公明andGitHub fbef2afd68 Add files via upload 2026-07-22 14:19:38 +08:00
公明andGitHub bccb324d2b Add files via upload 2026-07-22 14:17:23 +08:00
公明andGitHub b28fba3d68 Add files via upload 2026-07-22 14:14:42 +08:00
公明andGitHub 8059b6d5b2 Add files via upload 2026-07-22 14:12:36 +08:00
公明andGitHub 0821bb2911 Add files via upload 2026-07-22 14:10:44 +08:00
公明andGitHub e407f8203c Add files via upload 2026-07-22 13:59:37 +08:00
公明andGitHub a59253e828 Add files via upload 2026-07-22 13:51:36 +08:00
公明andGitHub 4c989fbbe0 Add files via upload 2026-07-22 13:47:24 +08:00
公明andGitHub 91239c9869 Add files via upload 2026-07-22 13:41:43 +08:00
公明andGitHub 6892156b7c Add files via upload 2026-07-22 13:36:39 +08:00
公明andGitHub 75489123f9 Add files via upload 2026-07-22 11:33:54 +08:00
公明andGitHub 631ee6c447 Add files via upload 2026-07-22 11:30:11 +08:00
公明andGitHub 7caf77683f Add files via upload 2026-07-22 11:27:34 +08:00
公明andGitHub fce4ffe3c4 Add files via upload 2026-07-22 11:11:52 +08:00
公明andGitHub 75163f9269 Add files via upload 2026-07-22 10:37:19 +08:00
公明andGitHub 06a9cea97d Add files via upload 2026-07-22 10:34:42 +08:00
公明andGitHub b62ba3b214 Add files via upload 2026-07-22 10:31:20 +08:00
44 changed files with 2126 additions and 333 deletions
+1
View File
@@ -34,6 +34,7 @@ func main() {
// 注册工具 // 注册工具
executor.RegisterTools(mcpServer) executor.RegisterTools(mcpServer)
mcp.RegisterExecutionControlTools(mcpServer, nil)
log.Logger.Info("MCP服务器(stdio模式)已启动,等待消息...") log.Logger.Info("MCP服务器(stdio模式)已启动,等待消息...")
+1 -1
View File
@@ -10,7 +10,7 @@
# ============================================ # ============================================
# 前端显示的版本号(可选,不填则显示默认版本) # 前端显示的版本号(可选,不填则显示默认版本)
version: "v1.7.6" version: "v1.7.7"
# 服务器配置 # 服务器配置
server: server:
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口 host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
+9
View File
@@ -51,6 +51,13 @@ Important: when `wait_tool_execution` reaches `timeout_seconds` and the target e
| `wait_tool_execution` | Wait for a selected execution for a bounded interval | | `wait_tool_execution` | Wait for a selected execution for a bounded interval |
| `cancel_tool_execution` | Cancel a selected execution | | `cancel_tool_execution` | Cancel a selected execution |
`get_tool_execution` and `wait_tool_execution` can include a live output preview:
- `include_partial_output`: whether to return partial output, default `true`.
- `partial_output_max_bytes`: tail preview limit for this call, default `4096`, maximum `65536`.
Partial output is a bounded preview of output produced so far, not the final `result`. The canonical `result` is still written only when the tool finishes. Tools that do not support streaming output simply omit partial fields.
Typical flow: Typical flow:
```text ```text
@@ -60,6 +67,8 @@ Typical flow:
4. If still incomplete, continue waiting or call cancel_tool_execution 4. If still incomplete, continue waiting or call cancel_tool_execution
``` ```
`tool_wait_timeout_seconds` applies to internal MCP tools, external MCP tools, and Eino filesystem's streaming `execute`. Eino's non-streaming filesystem tools such as `ls/read_file/write_file/edit_file/glob/grep` are recorded in execution monitoring, but they are not converted into resumable background workers.
## Cancellation and Session Cleanup ## Cancellation and Session Cleanup
- User stop cancels running tools for the current conversation. - User stop cancels running tools for the current conversation.
+9
View File
@@ -51,6 +51,13 @@ Agent 调用工具
| `wait_tool_execution` | 等待指定 execution 一段时间 | | `wait_tool_execution` | 等待指定 execution 一段时间 |
| `cancel_tool_execution` | 主动取消指定 execution | | `cancel_tool_execution` | 主动取消指定 execution |
`get_tool_execution``wait_tool_execution` 支持返回运行中输出预览:
- `include_partial_output`:是否返回 partial output,默认 `true`
- `partial_output_max_bytes`:本次返回的尾部预览上限,默认 `4096`,最大 `65536`
partial output 是“已产生输出的有界预览”,不等同于最终 `result`。最终 `result` 仍只在工具结束时写入 canonical execution 记录;不支持流式输出的工具不会返回 partial 字段。
典型流程: 典型流程:
```text ```text
@@ -60,6 +67,8 @@ Agent 调用工具
4. 仍未完成时可继续等待,或调用 cancel_tool_execution 4. 仍未完成时可继续等待,或调用 cancel_tool_execution
``` ```
`tool_wait_timeout_seconds` 适用于内部 MCP、外部 MCP,以及 Eino filesystem 的流式 `execute`。Eino 的 `ls/read_file/write_file/edit_file/glob/grep` 等非流式 filesystem 工具会写入 execution 监控记录,但不作为后台 worker 做软等待续跑。
## 取消和会话清理 ## 取消和会话清理
- 用户点击“停止任务”时,会取消当前会话仍在运行的工具。 - 用户点击“停止任务”时,会取消当前会话仍在运行的工具。
+34 -27
View File
@@ -24,18 +24,17 @@ import (
// Agent AI代理 // Agent AI代理
type Agent struct { type Agent struct {
openAIClient *openai.Client openAIClient *openai.Client
config *config.OpenAIConfig config *config.OpenAIConfig
agentConfig *config.AgentConfig agentConfig *config.AgentConfig
mcpServer *mcp.Server mcpServer *mcp.Server
externalMCPMgr *mcp.ExternalMCPManager // 外部MCP管理器 externalMCPMgr *mcp.ExternalMCPManager // 外部MCP管理器
logger *zap.Logger logger *zap.Logger
maxIterations int maxIterations int
mu sync.RWMutex // 添加互斥锁以支持并发更新 mu sync.RWMutex // 添加互斥锁以支持并发更新
toolNameMapping map[string]string // 工具名称映射:OpenAI格式 -> 原始格式(用于外部MCP工具) toolNameMapping map[string]string // 工具名称映射:OpenAI格式 -> 原始格式(用于外部MCP工具)
currentConversationID string // 当前对话ID(用于自动传递给工具 promptBaseDir string // 解析 system_prompt_path 时相对路径的基准目录(通常为 config.yaml 所在目录
promptBaseDir string // 解析 system_prompt_path 时相对路径的基准目录(通常为 config.yaml 所在目录) toolDescriptionMode string // 工具描述模式: "short" | "full",默认 short
toolDescriptionMode string // 工具描述模式: "short" | "full",默认 short
} }
type agentConversationIDKey struct{} type agentConversationIDKey struct{}
@@ -526,12 +525,6 @@ func (a *Agent) executeToolViaMCP(ctx context.Context, toolName string, args map
// 如果是record_vulnerability工具,自动添加conversation_id // 如果是record_vulnerability工具,自动添加conversation_id
if toolName == builtin.ToolRecordVulnerability { if toolName == builtin.ToolRecordVulnerability {
conversationID := agentConversationIDFromContext(ctx) conversationID := agentConversationIDFromContext(ctx)
if conversationID == "" {
a.mu.RLock()
conversationID = a.currentConversationID
a.mu.RUnlock()
}
if conversationID != "" { if conversationID != "" {
args["conversation_id"] = conversationID args["conversation_id"] = conversationID
a.logger.Debug("自动添加conversation_id到record_vulnerability工具", a.logger.Debug("自动添加conversation_id到record_vulnerability工具",
@@ -769,16 +762,8 @@ func (a *Agent) ToolsForRole(roleTools []string) []Tool {
// ExecuteMCPToolForConversation 在指定会话上下文中执行 MCP 工具(行为与主 Agent 循环中的工具调用一致,如自动注入 conversation_id)。 // ExecuteMCPToolForConversation 在指定会话上下文中执行 MCP 工具(行为与主 Agent 循环中的工具调用一致,如自动注入 conversation_id)。
func (a *Agent) ExecuteMCPToolForConversation(ctx context.Context, conversationID, toolName string, args map[string]interface{}) (*ToolExecutionResult, error) { func (a *Agent) ExecuteMCPToolForConversation(ctx context.Context, conversationID, toolName string, args map[string]interface{}) (*ToolExecutionResult, error) {
a.mu.Lock()
prev := a.currentConversationID
a.currentConversationID = conversationID
a.mu.Unlock()
defer func() {
a.mu.Lock()
a.currentConversationID = prev
a.mu.Unlock()
}()
ctx = withAgentConversationID(ctx, conversationID) ctx = withAgentConversationID(ctx, conversationID)
ctx = mcp.WithMCPConversationID(ctx, conversationID)
return a.executeToolViaMCP(ctx, toolName, args) return a.executeToolViaMCP(ctx, toolName, args)
} }
@@ -798,6 +783,28 @@ func (a *Agent) FinishLocalToolExecution(ctx context.Context, executionID, toolN
return a.mcpServer.FinishToolExecution(ctx, executionID, toolName, args, resultText, invokeErr) return a.mcpServer.FinishToolExecution(ctx, executionID, toolName, args, resultText, invokeErr)
} }
// AppendLocalToolExecutionPartialOutput records a bounded live-output preview for a running local tool.
func (a *Agent) AppendLocalToolExecutionPartialOutput(executionID, chunk string) {
if a == nil || a.mcpServer == nil {
return
}
a.mcpServer.AppendToolExecutionPartialOutput(executionID, chunk)
}
func (a *Agent) RegisterLocalToolExecutionCancel(executionID string, cancel context.CancelFunc) {
if a == nil || a.mcpServer == nil {
return
}
a.mcpServer.RegisterToolExecutionCancel(executionID, cancel)
}
func (a *Agent) UnregisterLocalToolExecutionCancel(executionID string) {
if a == nil || a.mcpServer == nil {
return
}
a.mcpServer.UnregisterToolExecutionCancel(executionID)
}
// RecordLocalToolExecution 将非 CallTool 路径完成的工具调用写入 MCP 监控库(与 CallTool 落库一致),返回 executionId。 // RecordLocalToolExecution 将非 CallTool 路径完成的工具调用写入 MCP 监控库(与 CallTool 落库一致),返回 executionId。
// 用于 Eino filesystem execute 等场景,使助手气泡「渗透测试详情」与常规 MCP 一致可点进监控。 // 用于 Eino filesystem execute 等场景,使助手气泡「渗透测试详情」与常规 MCP 一致可点进监控。
func (a *Agent) RecordLocalToolExecution(ctx context.Context, toolName string, args map[string]interface{}, resultText string, invokeErr error) string { func (a *Agent) RecordLocalToolExecution(ctx context.Context, toolName string, args map[string]interface{}, resultText string, invokeErr error) string {
+92
View File
@@ -3,11 +3,13 @@ package agent
import ( import (
"context" "context"
"strings" "strings"
"sync"
"testing" "testing"
"time" "time"
"cyberstrike-ai/internal/config" "cyberstrike-ai/internal/config"
"cyberstrike-ai/internal/mcp" "cyberstrike-ai/internal/mcp"
"cyberstrike-ai/internal/mcp/builtin"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -116,3 +118,93 @@ func TestAgentCancelRunningMCPToolsForConversation(t *testing.T) {
} }
t.Fatal("conv-1 execution did not become cancelled") t.Fatal("conv-1 execution did not become cancelled")
} }
func TestExecuteMCPToolForConversationInjectsConversationID(t *testing.T) {
ag := setupTestAgent(t)
gotArgs := make(chan map[string]interface{}, 1)
ag.mcpServer.RegisterTool(mcp.Tool{Name: builtin.ToolRecordVulnerability, InputSchema: map[string]interface{}{"type": "object"}}, func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
gotArgs <- args
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "ok"}}}, nil
})
result, err := ag.ExecuteMCPToolForConversation(context.Background(), "conv-record", builtin.ToolRecordVulnerability, map[string]interface{}{})
if err != nil {
t.Fatalf("ExecuteMCPToolForConversation: %v", err)
}
if result == nil || result.IsError {
t.Fatalf("expected successful result, got %#v", result)
}
select {
case args := <-gotArgs:
if got := args["conversation_id"]; got != "conv-record" {
t.Fatalf("conversation_id = %#v, want conv-record", got)
}
case <-time.After(time.Second):
t.Fatal("tool was not called")
}
}
func TestExecuteMCPToolForConversationBindsExecutionConversation(t *testing.T) {
ag := setupTestAgent(t)
ag.mcpServer.ConfigureToolWaitTimeoutSeconds(1)
release := make(chan struct{})
ag.mcpServer.RegisterTool(mcp.Tool{Name: "slow-bind", InputSchema: map[string]interface{}{"type": "object"}}, func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
select {
case <-release:
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "done"}}}, nil
case <-ctx.Done():
return nil, ctx.Err()
}
})
result, err := ag.ExecuteMCPToolForConversation(context.Background(), "conv-bound", "slow-bind", nil)
if err != nil {
t.Fatalf("ExecuteMCPToolForConversation: %v", err)
}
if result == nil || !result.IsError || result.ExecutionID == "" {
t.Fatalf("expected bounded wait result with execution id, result=%#v", result)
}
exec, ok := ag.mcpServer.GetExecution(result.ExecutionID)
if !ok || exec == nil {
t.Fatalf("missing execution %q", result.ExecutionID)
}
if exec.ConversationID != "conv-bound" {
t.Fatalf("execution conversation = %q, want conv-bound", exec.ConversationID)
}
close(release)
}
func TestExecuteMCPToolForConversationConcurrentRecordIsolation(t *testing.T) {
ag := setupTestAgent(t)
seen := make(chan string, 2)
ag.mcpServer.RegisterTool(mcp.Tool{Name: builtin.ToolRecordVulnerability, InputSchema: map[string]interface{}{"type": "object"}}, func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
if conv, _ := args["conversation_id"].(string); conv != "" {
seen <- conv
}
return &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: "ok"}}}, nil
})
var wg sync.WaitGroup
for _, conv := range []string{"conv-a", "conv-b"} {
conv := conv
wg.Add(1)
go func() {
defer wg.Done()
if _, err := ag.ExecuteMCPToolForConversation(context.Background(), conv, builtin.ToolRecordVulnerability, map[string]interface{}{}); err != nil {
t.Errorf("ExecuteMCPToolForConversation %s: %v", conv, err)
}
}()
}
wg.Wait()
close(seen)
got := map[string]int{}
for conv := range seen {
got[conv]++
}
if got["conv-a"] != 1 || got["conv-b"] != 1 {
t.Fatalf("conversation ids = %#v, want one call for conv-a and conv-b", got)
}
}
+1
View File
@@ -1337,6 +1337,7 @@ func setupRoutes(
// 对话附件(chat_uploads)管理 // 对话附件(chat_uploads)管理
protected.GET("/chat-uploads", chatUploadsHandler.List) protected.GET("/chat-uploads", chatUploadsHandler.List)
protected.GET("/chat-uploads/export", chatUploadsHandler.Export)
protected.GET("/chat-uploads/download", chatUploadsHandler.Download) protected.GET("/chat-uploads/download", chatUploadsHandler.Download)
protected.GET("/chat-uploads/content", chatUploadsHandler.GetContent) protected.GET("/chat-uploads/content", chatUploadsHandler.GetContent)
protected.POST("/chat-uploads", chatUploadsHandler.Upload) protected.POST("/chat-uploads", chatUploadsHandler.Upload)
+69 -95
View File
@@ -22,6 +22,7 @@ type Conversation struct {
ID string `json:"id"` ID string `json:"id"`
Title string `json:"title"` Title string `json:"title"`
ProjectID string `json:"projectId,omitempty"` ProjectID string `json:"projectId,omitempty"`
RoleName string `json:"roleName,omitempty"`
Pinned bool `json:"pinned"` Pinned bool `json:"pinned"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
@@ -57,29 +58,30 @@ func (db *DB) CreateConversationWithWebshell(webshellConnectionID, title string,
return nil, err return nil, err
} }
} }
roleName := normalizeConversationRoleName(meta.RoleName)
var err error var err error
wsID := strings.TrimSpace(webshellConnectionID) wsID := strings.TrimSpace(webshellConnectionID)
switch { switch {
case wsID != "" && projectID != "": case wsID != "" && projectID != "":
_, err = db.Exec( _, err = db.Exec(
"INSERT INTO conversations (id, title, created_at, updated_at, webshell_connection_id, project_id) VALUES (?, ?, ?, ?, ?, ?)", "INSERT INTO conversations (id, title, created_at, updated_at, webshell_connection_id, project_id, role_name) VALUES (?, ?, ?, ?, ?, ?, ?)",
id, title, now, now, wsID, projectID, id, title, now, now, wsID, projectID, roleName,
) )
case wsID != "": case wsID != "":
_, err = db.Exec( _, err = db.Exec(
"INSERT INTO conversations (id, title, created_at, updated_at, webshell_connection_id) VALUES (?, ?, ?, ?, ?)", "INSERT INTO conversations (id, title, created_at, updated_at, webshell_connection_id, role_name) VALUES (?, ?, ?, ?, ?, ?)",
id, title, now, now, wsID, id, title, now, now, wsID, roleName,
) )
case projectID != "": case projectID != "":
_, err = db.Exec( _, err = db.Exec(
"INSERT INTO conversations (id, title, created_at, updated_at, project_id) VALUES (?, ?, ?, ?, ?)", "INSERT INTO conversations (id, title, created_at, updated_at, project_id, role_name) VALUES (?, ?, ?, ?, ?, ?)",
id, title, now, now, projectID, id, title, now, now, projectID, roleName,
) )
default: default:
_, err = db.Exec( _, err = db.Exec(
"INSERT INTO conversations (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)", "INSERT INTO conversations (id, title, created_at, updated_at, role_name) VALUES (?, ?, ?, ?, ?)",
id, title, now, now, id, title, now, now, roleName,
) )
} }
if err != nil { if err != nil {
@@ -90,6 +92,7 @@ func (db *DB) CreateConversationWithWebshell(webshellConnectionID, title string,
ID: id, ID: id,
Title: title, Title: title,
ProjectID: projectID, ProjectID: projectID,
RoleName: roleName,
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
} }
@@ -236,10 +239,11 @@ func (db *DB) GetConversation(id string) (*Conversation, error) {
var pinned int var pinned int
var projectID sql.NullString var projectID sql.NullString
var roleName sql.NullString
err := db.QueryRow( err := db.QueryRow(
"SELECT id, title, pinned, created_at, updated_at, project_id FROM conversations WHERE id = ?", "SELECT id, title, pinned, created_at, updated_at, project_id, role_name FROM conversations WHERE id = ?",
id, id,
).Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID) ).Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID, &roleName)
if err != nil { if err != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, fmt.Errorf("对话不存在") return nil, fmt.Errorf("对话不存在")
@@ -249,6 +253,9 @@ func (db *DB) GetConversation(id string) (*Conversation, error) {
if projectID.Valid { if projectID.Valid {
conv.ProjectID = strings.TrimSpace(projectID.String) conv.ProjectID = strings.TrimSpace(projectID.String)
} }
if roleName.Valid {
conv.RoleName = normalizeConversationRoleName(roleName.String)
}
// 尝试多种时间格式解析 // 尝试多种时间格式解析
var err1, err2 error var err1, err2 error
@@ -322,10 +329,11 @@ func (db *DB) GetConversationLite(id string) (*Conversation, error) {
var pinned int var pinned int
var projectID sql.NullString var projectID sql.NullString
var roleName sql.NullString
err := db.QueryRow( err := db.QueryRow(
"SELECT id, title, pinned, created_at, updated_at, project_id FROM conversations WHERE id = ?", "SELECT id, title, pinned, created_at, updated_at, project_id, role_name FROM conversations WHERE id = ?",
id, id,
).Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID) ).Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID, &roleName)
if err != nil { if err != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, fmt.Errorf("对话不存在") return nil, fmt.Errorf("对话不存在")
@@ -335,6 +343,9 @@ func (db *DB) GetConversationLite(id string) (*Conversation, error) {
if projectID.Valid { if projectID.Valid {
conv.ProjectID = strings.TrimSpace(projectID.String) conv.ProjectID = strings.TrimSpace(projectID.String)
} }
if roleName.Valid {
conv.RoleName = normalizeConversationRoleName(roleName.String)
}
// 尝试多种时间格式解析 // 尝试多种时间格式解析
var err1, err2 error var err1, err2 error
@@ -365,6 +376,26 @@ func (db *DB) GetConversationLite(id string) (*Conversation, error) {
return &conv, nil return &conv, nil
} }
func normalizeConversationRoleName(roleName string) string {
roleName = strings.TrimSpace(roleName)
if roleName == "" {
return "默认"
}
return roleName
}
func (db *DB) SetConversationRoleName(id, roleName string) error {
roleName = normalizeConversationRoleName(roleName)
_, err := db.Exec(
"UPDATE conversations SET role_name = ?, updated_at = ? WHERE id = ?",
roleName, time.Now(), id,
)
if err != nil {
return fmt.Errorf("更新对话角色失败: %w", err)
}
return nil
}
func conversationProjectIDColumn(alias string) string { func conversationProjectIDColumn(alias string) string {
if alias != "" { if alias != "" {
return alias + ".project_id" return alias + ".project_id"
@@ -489,7 +520,7 @@ func (db *DB) ListConversations(limit, offset int, search, sortBy, projectID str
where, args = appendConversationProjectFilter(where, args, projectID, "c") where, args = appendConversationProjectFilter(where, args, projectID, "c")
args = append(args, limit, offset) args = append(args, limit, offset)
rows, err = db.Query( rows, err = db.Query(
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id `SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name
FROM conversations c`+where+` FROM conversations c`+where+`
`+orderClause+` `+orderClause+`
LIMIT ? OFFSET ?`, LIMIT ? OFFSET ?`,
@@ -505,7 +536,7 @@ func (db *DB) ListConversations(limit, offset int, search, sortBy, projectID str
} }
args = append(args, limit, offset) args = append(args, limit, offset)
rows, err = db.Query( rows, err = db.Query(
"SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id FROM conversations"+where+" "+orderClause+" LIMIT ? OFFSET ?", "SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id, role_name FROM conversations"+where+" "+orderClause+" LIMIT ? OFFSET ?",
args..., args...,
) )
} }
@@ -514,45 +545,7 @@ func (db *DB) ListConversations(limit, offset int, search, sortBy, projectID str
return nil, fmt.Errorf("查询对话列表失败: %w", err) return nil, fmt.Errorf("查询对话列表失败: %w", err)
} }
defer rows.Close() defer rows.Close()
return scanConversationRows(rows)
var conversations []*Conversation
for rows.Next() {
var conv Conversation
var createdAt, updatedAt string
var pinned int
var projectID sql.NullString
if err := rows.Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID); err != nil {
return nil, fmt.Errorf("扫描对话失败: %w", err)
}
if projectID.Valid {
conv.ProjectID = strings.TrimSpace(projectID.String)
}
// 尝试多种时间格式解析
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
conversations = append(conversations, &conv)
}
return conversations, nil
} }
func (db *DB) ListConversationsForAccess(limit, offset int, search, sortBy, projectID, userID, scope string) ([]*Conversation, error) { func (db *DB) ListConversationsForAccess(limit, offset int, search, sortBy, projectID, userID, scope string) ([]*Conversation, error) {
@@ -571,7 +564,7 @@ func (db *DB) ListConversationsForAccess(limit, offset int, search, sortBy, proj
where, args = appendConversationAccessFilter(where, args, userID, scope, "c") where, args = appendConversationAccessFilter(where, args, userID, scope, "c")
args = append(args, limit, offset) args = append(args, limit, offset)
rows, err = db.Query( rows, err = db.Query(
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id `SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name
FROM conversations c`+where+` FROM conversations c`+where+`
`+orderClause+` `+orderClause+`
LIMIT ? OFFSET ?`, args...) LIMIT ? OFFSET ?`, args...)
@@ -586,7 +579,7 @@ func (db *DB) ListConversationsForAccess(limit, offset int, search, sortBy, proj
} }
args = append(args, limit, offset) args = append(args, limit, offset)
rows, err = db.Query( rows, err = db.Query(
"SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id FROM conversations"+where+" "+orderClause+" LIMIT ? OFFSET ?", "SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id, role_name FROM conversations"+where+" "+orderClause+" LIMIT ? OFFSET ?",
args...) args...)
} }
if err != nil { if err != nil {
@@ -603,12 +596,16 @@ func scanConversationRows(rows *sql.Rows) ([]*Conversation, error) {
var createdAt, updatedAt string var createdAt, updatedAt string
var pinned int var pinned int
var projectID sql.NullString var projectID sql.NullString
if err := rows.Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID); err != nil { var roleName sql.NullString
if err := rows.Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID, &roleName); err != nil {
return nil, fmt.Errorf("扫描对话失败: %w", err) return nil, fmt.Errorf("扫描对话失败: %w", err)
} }
if projectID.Valid { if projectID.Valid {
conv.ProjectID = strings.TrimSpace(projectID.String) conv.ProjectID = strings.TrimSpace(projectID.String)
} }
if roleName.Valid {
conv.RoleName = normalizeConversationRoleName(roleName.String)
}
var err1, err2 error var err1, err2 error
conv.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05.999999999-07:00", createdAt) conv.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05.999999999-07:00", createdAt)
if err1 != nil { if err1 != nil {
@@ -668,7 +665,7 @@ func (db *DB) ListUngroupedConversations(limit, offset int, sortBy, projectID st
where, args = appendConversationProjectFilter(where, args, projectID, "c") where, args = appendConversationProjectFilter(where, args, projectID, "c")
args = append(args, limit, offset) args = append(args, limit, offset)
rows, err := db.Query( rows, err := db.Query(
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id `+ `SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name `+
where+` where+`
`+orderClause+` `+orderClause+`
LIMIT ? OFFSET ?`, LIMIT ? OFFSET ?`,
@@ -678,43 +675,7 @@ func (db *DB) ListUngroupedConversations(limit, offset int, sortBy, projectID st
return nil, fmt.Errorf("查询未分组对话失败: %w", err) return nil, fmt.Errorf("查询未分组对话失败: %w", err)
} }
defer rows.Close() defer rows.Close()
return scanConversationRows(rows)
var conversations []*Conversation
for rows.Next() {
var conv Conversation
var createdAt, updatedAt string
var pinned int
var projectID sql.NullString
if err := rows.Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID); err != nil {
return nil, fmt.Errorf("扫描对话失败: %w", err)
}
if projectID.Valid {
conv.ProjectID = strings.TrimSpace(projectID.String)
}
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
conversations = append(conversations, &conv)
}
return conversations, rows.Err()
} }
func (db *DB) ListUngroupedConversationsForAccess(limit, offset int, sortBy, projectID, userID, scope string) ([]*Conversation, error) { func (db *DB) ListUngroupedConversationsForAccess(limit, offset int, sortBy, projectID, userID, scope string) ([]*Conversation, error) {
@@ -728,7 +689,7 @@ func (db *DB) ListUngroupedConversationsForAccess(limit, offset int, sortBy, pro
where, args = appendConversationAccessFilter(where, args, userID, scope, "c") where, args = appendConversationAccessFilter(where, args, userID, scope, "c")
args = append(args, limit, offset) args = append(args, limit, offset)
rows, err := db.Query( rows, err := db.Query(
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id `+ `SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id, c.role_name `+
where+` where+`
`+orderClause+` `+orderClause+`
LIMIT ? OFFSET ?`, LIMIT ? OFFSET ?`,
@@ -861,6 +822,19 @@ func (db *DB) einoReductionBaseDir() string {
return filepath.Join("tmp", "reduction") return filepath.Join("tmp", "reduction")
} }
// EinoReductionBaseDir returns the configured reduction cache root.
func (db *DB) EinoReductionBaseDir() string {
return db.einoReductionBaseDir()
}
// ConversationArtifactsBaseDir returns the conversation-scoped artifacts root.
func (db *DB) ConversationArtifactsBaseDir() string {
if db == nil {
return ""
}
return strings.TrimSpace(db.conversationArtifactsDir)
}
func (db *DB) einoWorkspaceBaseDir() string { func (db *DB) einoWorkspaceBaseDir() string {
if db == nil { if db == nil {
return "" return ""
@@ -5,6 +5,7 @@ type ConversationCreateMeta struct {
Source string Source string
WebShellConnectionID string WebShellConnectionID string
ProjectID string ProjectID string
RoleName string
ClientIP string ClientIP string
SessionHint string SessionHint string
} }
+40
View File
@@ -183,6 +183,7 @@ func (db *DB) initTables() error {
title TEXT NOT NULL, title TEXT NOT NULL,
created_at DATETIME NOT NULL, created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL, updated_at DATETIME NOT NULL,
role_name TEXT NOT NULL DEFAULT '默认',
last_react_input TEXT, last_react_input TEXT,
last_react_output TEXT last_react_output TEXT
);` );`
@@ -226,6 +227,10 @@ func (db *DB) initTables() error {
start_time DATETIME NOT NULL, start_time DATETIME NOT NULL,
end_time DATETIME, end_time DATETIME,
duration_ms INTEGER, duration_ms INTEGER,
partial_output TEXT,
partial_output_bytes INTEGER NOT NULL DEFAULT 0,
partial_output_truncated INTEGER NOT NULL DEFAULT 0,
partial_output_updated_at DATETIME,
owner_user_id TEXT, owner_user_id TEXT,
conversation_id TEXT, conversation_id TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
@@ -954,6 +959,9 @@ func (db *DB) initTables() error {
if err := db.migrateWorkflowRunsTable(); err != nil { if err := db.migrateWorkflowRunsTable(); err != nil {
db.logger.Warn("迁移workflow_runs表失败", zap.Error(err)) db.logger.Warn("迁移workflow_runs表失败", zap.Error(err))
} }
if err := db.migrateToolExecutionsPartialOutputColumns(); err != nil {
db.logger.Warn("迁移tool_executions partial output字段失败", zap.Error(err))
}
if err := db.migrateRBACOwnershipColumns(); err != nil { if err := db.migrateRBACOwnershipColumns(); err != nil {
db.logger.Warn("迁移RBAC资源归属字段失败", zap.Error(err)) db.logger.Warn("迁移RBAC资源归属字段失败", zap.Error(err))
} }
@@ -978,6 +986,23 @@ func (db *DB) migrateRobotUserSessionsTable() error {
return nil return nil
} }
func (db *DB) migrateToolExecutionsPartialOutputColumns() error {
for _, col := range []struct {
name string
stmt string
}{
{"partial_output", "ALTER TABLE tool_executions ADD COLUMN partial_output TEXT"},
{"partial_output_bytes", "ALTER TABLE tool_executions ADD COLUMN partial_output_bytes INTEGER NOT NULL DEFAULT 0"},
{"partial_output_truncated", "ALTER TABLE tool_executions ADD COLUMN partial_output_truncated INTEGER NOT NULL DEFAULT 0"},
{"partial_output_updated_at", "ALTER TABLE tool_executions ADD COLUMN partial_output_updated_at DATETIME"},
} {
if err := db.addColumnIfMissing("tool_executions", col.name, col.stmt); err != nil {
return err
}
}
return nil
}
// migrateAssetsTable keeps databases created by the first asset-management release compatible. // migrateAssetsTable keeps databases created by the first asset-management release compatible.
func (db *DB) migrateAssetsTable() error { func (db *DB) migrateAssetsTable() error {
columns := []struct { columns := []struct {
@@ -1127,6 +1152,21 @@ func (db *DB) migrateConversationsTable() error {
} }
} }
// 检查 role_name 字段是否存在(对话绑定的业务角色,用于历史任务切换时恢复角色上下文)
err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('conversations') WHERE name='role_name'").Scan(&count)
if err != nil {
if _, addErr := db.Exec("ALTER TABLE conversations ADD COLUMN role_name TEXT NOT NULL DEFAULT '默认'"); addErr != nil {
errMsg := strings.ToLower(addErr.Error())
if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") {
db.logger.Warn("添加role_name字段失败", zap.Error(addErr))
}
}
} else if count == 0 {
if _, err := db.Exec("ALTER TABLE conversations ADD COLUMN role_name TEXT NOT NULL DEFAULT '默认'"); err != nil {
db.logger.Warn("添加role_name字段失败", zap.Error(err))
}
}
return nil return nil
} }
+57 -20
View File
@@ -43,11 +43,19 @@ func (db *DB) SaveToolExecution(exec *mcp.ToolExecution) error {
if exec.Duration > 0 { if exec.Duration > 0 {
durationMs = sql.NullInt64{Int64: exec.Duration.Milliseconds(), Valid: true} durationMs = sql.NullInt64{Int64: exec.Duration.Milliseconds(), Valid: true}
} }
var partialUpdatedAt sql.NullTime
if exec.PartialOutputUpdatedAt != nil {
partialUpdatedAt = sql.NullTime{Time: *exec.PartialOutputUpdatedAt, Valid: true}
}
partialTruncated := 0
if exec.PartialOutputTruncated {
partialTruncated = 1
}
query := ` query := `
INSERT OR REPLACE INTO tool_executions INSERT OR REPLACE INTO tool_executions
(id, tool_name, arguments, status, result, error, start_time, end_time, duration_ms, owner_user_id, conversation_id, created_at) (id, tool_name, arguments, status, result, error, start_time, end_time, duration_ms, partial_output, partial_output_bytes, partial_output_truncated, partial_output_updated_at, owner_user_id, conversation_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
` `
_, err = db.Exec(query, _, err = db.Exec(query,
@@ -60,6 +68,10 @@ func (db *DB) SaveToolExecution(exec *mcp.ToolExecution) error {
exec.StartTime, exec.StartTime,
endTime, endTime,
durationMs, durationMs,
sqlNullString(exec.PartialOutput),
exec.PartialOutputBytes,
partialTruncated,
partialUpdatedAt,
strings.TrimSpace(exec.OwnerUserID), strings.TrimSpace(exec.OwnerUserID),
strings.TrimSpace(exec.ConversationID), strings.TrimSpace(exec.ConversationID),
time.Now(), time.Now(),
@@ -90,6 +102,13 @@ func (db *DB) UpdateToolExecutionResult(id string, result *mcp.ToolResult) error
return err return err
} }
func sqlNullString(s string) sql.NullString {
if s == "" {
return sql.NullString{}
}
return sql.NullString{String: s, Valid: true}
}
// CountToolExecutions 统计工具执行记录总数 // CountToolExecutions 统计工具执行记录总数
func (db *DB) CountToolExecutions(status, toolName string) (int, error) { func (db *DB) CountToolExecutions(status, toolName string) (int, error) {
return db.CountToolExecutionsForAccess(status, toolName, RBACListAccess{Scope: RBACScopeAll}) return db.CountToolExecutionsForAccess(status, toolName, RBACListAccess{Scope: RBACScopeAll})
@@ -267,7 +286,8 @@ type ToolStatsSummaryResult struct {
TopTools []*mcp.ToolStats TopTools []*mcp.ToolStats
} }
// LoadToolStatsSummary 聚合统计信息,仅返回汇总与 Top N 工具(避免全量 map 传输) // LoadToolStatsSummary 聚合统计信息,仅返回汇总与 Top N 工具(避免全量 map 传输)
// 监控页的失败口径只包含真实失败/异常终止;用户主动取消的 cancelled 保留在总调用中,不计入失败。
func (db *DB) LoadToolStatsSummary(topN int) (*ToolStatsSummaryResult, error) { func (db *DB) LoadToolStatsSummary(topN int) (*ToolStatsSummaryResult, error) {
if topN <= 0 { if topN <= 0 {
topN = 6 topN = 6
@@ -282,19 +302,19 @@ func (db *DB) LoadToolStatsSummary(topN int) (*ToolStatsSummaryResult, error) {
summaryQuery := ` summaryQuery := `
SELECT COUNT(*), SELECT COUNT(*),
COALESCE(SUM(total_calls), 0), COALESCE(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(success_calls), 0), COALESCE(SUM(CASE WHEN status IN ('failed', 'hard_timeout', 'orphaned') THEN 1 ELSE 0 END), 0),
COALESCE(SUM(failed_calls), 0), MAX(start_time),
MAX(last_call_time) COUNT(DISTINCT tool_name)
FROM tool_stats FROM tool_executions
` `
var lastCallRaw sql.NullString var lastCallRaw sql.NullString
err := db.QueryRow(summaryQuery).Scan( err := db.QueryRow(summaryQuery).Scan(
&result.Summary.ToolCount,
&result.Summary.TotalCalls, &result.Summary.TotalCalls,
&result.Summary.SuccessCalls, &result.Summary.SuccessCalls,
&result.Summary.FailedCalls, &result.Summary.FailedCalls,
&lastCallRaw, &lastCallRaw,
&result.Summary.ToolCount,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@@ -310,9 +330,13 @@ func (db *DB) LoadToolStatsSummary(topN int) (*ToolStatsSummaryResult, error) {
} }
topQuery := ` topQuery := `
SELECT tool_name, total_calls, success_calls, failed_calls, last_call_time SELECT tool_name,
FROM tool_stats COUNT(*) AS total_calls,
WHERE total_calls > 0 SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) AS success_calls,
SUM(CASE WHEN status IN ('failed', 'hard_timeout', 'orphaned') THEN 1 ELSE 0 END) AS failed_calls,
MAX(start_time) AS last_call_time
FROM tool_executions
GROUP BY tool_name
ORDER BY total_calls DESC, tool_name ASC ORDER BY total_calls DESC, tool_name ASC
LIMIT ? LIMIT ?
` `
@@ -324,7 +348,7 @@ func (db *DB) LoadToolStatsSummary(topN int) (*ToolStatsSummaryResult, error) {
for rows.Next() { for rows.Next() {
var stat mcp.ToolStats var stat mcp.ToolStats
var lastCallTime sql.NullTime var lastCallTime sql.NullString
if err := rows.Scan( if err := rows.Scan(
&stat.ToolName, &stat.ToolName,
&stat.TotalCalls, &stat.TotalCalls,
@@ -336,7 +360,8 @@ func (db *DB) LoadToolStatsSummary(topN int) (*ToolStatsSummaryResult, error) {
continue continue
} }
if lastCallTime.Valid { if lastCallTime.Valid {
stat.LastCallTime = &lastCallTime.Time parsed := parseDBTime(lastCallTime.String)
stat.LastCallTime = &parsed
} }
result.TopTools = append(result.TopTools, &stat) result.TopTools = append(result.TopTools, &stat)
} }
@@ -359,7 +384,7 @@ func (db *DB) LoadToolStatsSummaryForAccess(topN int, access RBACListAccess) (*T
var lastCall sql.NullString var lastCall sql.NullString
err := db.QueryRow(`SELECT COUNT(*), err := db.QueryRow(`SELECT COUNT(*),
COALESCE(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), 0), COALESCE(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN status IN ('failed', 'cancelled', 'hard_timeout', 'orphaned') THEN 1 ELSE 0 END), 0), COALESCE(SUM(CASE WHEN status IN ('failed', 'hard_timeout', 'orphaned') THEN 1 ELSE 0 END), 0),
MAX(start_time), COUNT(DISTINCT tool_name)`+fromSQL, args...).Scan( MAX(start_time), COUNT(DISTINCT tool_name)`+fromSQL, args...).Scan(
&result.Summary.TotalCalls, &result.Summary.SuccessCalls, &result.Summary.FailedCalls, &result.Summary.TotalCalls, &result.Summary.SuccessCalls, &result.Summary.FailedCalls,
&lastCall, &result.Summary.ToolCount, &lastCall, &result.Summary.ToolCount,
@@ -373,7 +398,7 @@ func (db *DB) LoadToolStatsSummaryForAccess(topN int, access RBACListAccess) (*T
} }
rows, err := db.Query(`SELECT tool_name, COUNT(*), rows, err := db.Query(`SELECT tool_name, COUNT(*),
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END),
SUM(CASE WHEN status IN ('failed', 'cancelled', 'hard_timeout', 'orphaned') THEN 1 ELSE 0 END), MAX(start_time)`+ SUM(CASE WHEN status IN ('failed', 'hard_timeout', 'orphaned') THEN 1 ELSE 0 END), MAX(start_time)`+
fromSQL+` GROUP BY tool_name ORDER BY COUNT(*) DESC, tool_name ASC LIMIT ?`, append(args, topN)...) fromSQL+` GROUP BY tool_name ORDER BY COUNT(*) DESC, tool_name ASC LIMIT ?`, append(args, topN)...)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -482,7 +507,9 @@ func appendToolExecutionAccessSQL(query string, args []interface{}, access RBACL
// GetToolExecution 根据ID获取单条工具执行记录 // GetToolExecution 根据ID获取单条工具执行记录
func (db *DB) GetToolExecution(id string) (*mcp.ToolExecution, error) { func (db *DB) GetToolExecution(id string) (*mcp.ToolExecution, error) {
query := ` query := `
SELECT id, tool_name, arguments, status, result, error, start_time, end_time, duration_ms, COALESCE(owner_user_id, ''), COALESCE(conversation_id, '') SELECT id, tool_name, arguments, status, result, error, start_time, end_time, duration_ms,
COALESCE(partial_output, ''), COALESCE(partial_output_bytes, 0), COALESCE(partial_output_truncated, 0), partial_output_updated_at,
COALESCE(owner_user_id, ''), COALESCE(conversation_id, '')
FROM tool_executions FROM tool_executions
WHERE id = ? WHERE id = ?
` `
@@ -495,6 +522,8 @@ func (db *DB) GetToolExecution(id string) (*mcp.ToolExecution, error) {
var errorText sql.NullString var errorText sql.NullString
var endTime sql.NullTime var endTime sql.NullTime
var durationMs sql.NullInt64 var durationMs sql.NullInt64
var partialTruncated int
var partialUpdatedAt sql.NullTime
err := row.Scan( err := row.Scan(
&exec.ID, &exec.ID,
@@ -506,6 +535,10 @@ func (db *DB) GetToolExecution(id string) (*mcp.ToolExecution, error) {
&exec.StartTime, &exec.StartTime,
&endTime, &endTime,
&durationMs, &durationMs,
&exec.PartialOutput,
&exec.PartialOutputBytes,
&partialTruncated,
&partialUpdatedAt,
&exec.OwnerUserID, &exec.OwnerUserID,
&exec.ConversationID, &exec.ConversationID,
) )
@@ -538,6 +571,10 @@ func (db *DB) GetToolExecution(id string) (*mcp.ToolExecution, error) {
if durationMs.Valid { if durationMs.Valid {
exec.Duration = time.Duration(durationMs.Int64) * time.Millisecond exec.Duration = time.Duration(durationMs.Int64) * time.Millisecond
} }
exec.PartialOutputTruncated = partialTruncated != 0
if partialUpdatedAt.Valid {
exec.PartialOutputUpdatedAt = &partialUpdatedAt.Time
}
return &exec, nil return &exec, nil
} }
@@ -815,7 +852,7 @@ func (db *DB) PurgeToolExecutionsBefore(cutoff time.Time) (int64, error) {
} }
delta.totalCalls += count delta.totalCalls += count
switch status { switch status {
case "failed", "cancelled", "hard_timeout", "orphaned": case "failed", "hard_timeout", "orphaned":
delta.failedCalls += count delta.failedCalls += count
case "completed": case "completed":
delta.successCalls += count delta.successCalls += count
@@ -971,7 +1008,7 @@ func (db *DB) LoadCallsTimeline(since time.Time, dailyBuckets bool) ([]CallsTime
query = ` query = `
SELECT date(start_time, 'localtime') AS bucket, SELECT date(start_time, 'localtime') AS bucket,
COUNT(*) AS total, COUNT(*) AS total,
SUM(CASE WHEN status IN ('failed', 'cancelled', 'hard_timeout', 'orphaned') THEN 1 ELSE 0 END) AS failed SUM(CASE WHEN status IN ('failed', 'hard_timeout', 'orphaned') THEN 1 ELSE 0 END) AS failed
FROM tool_executions FROM tool_executions
WHERE start_time >= ? WHERE start_time >= ?
GROUP BY bucket GROUP BY bucket
@@ -981,7 +1018,7 @@ func (db *DB) LoadCallsTimeline(since time.Time, dailyBuckets bool) ([]CallsTime
query = ` query = `
SELECT strftime('%Y-%m-%d %H:00:00', start_time, 'localtime') AS bucket, SELECT strftime('%Y-%m-%d %H:00:00', start_time, 'localtime') AS bucket,
COUNT(*) AS total, COUNT(*) AS total,
SUM(CASE WHEN status IN ('failed', 'cancelled', 'hard_timeout', 'orphaned') THEN 1 ELSE 0 END) AS failed SUM(CASE WHEN status IN ('failed', 'hard_timeout', 'orphaned') THEN 1 ELSE 0 END) AS failed
FROM tool_executions FROM tool_executions
WHERE start_time >= ? WHERE start_time >= ?
GROUP BY bucket GROUP BY bucket
+46
View File
@@ -84,3 +84,49 @@ func TestLoadToolStatsSummaryAndListPage(t *testing.T) {
} }
} }
} }
func TestLoadToolStatsSummaryDoesNotCountCancelledAsFailed(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "monitor-cancelled-summary.db")
db, err := NewDB(dbPath, zap.NewNop())
if err != nil {
t.Fatalf("NewDB: %v", err)
}
defer db.Close()
now := time.Now()
for i, status := range []string{"completed", "cancelled", "failed"} {
exec := &mcp.ToolExecution{
ID: fmt.Sprintf("exec-%d", i),
ToolName: "exec",
Arguments: map[string]interface{}{},
Status: status,
StartTime: now.Add(time.Duration(i) * time.Second),
}
end := exec.StartTime.Add(time.Second)
exec.EndTime = &end
exec.Duration = time.Second
if err := db.SaveToolExecution(exec); err != nil {
t.Fatalf("SaveToolExecution(%s): %v", status, err)
}
}
summary, err := db.LoadToolStatsSummary(1)
if err != nil {
t.Fatalf("LoadToolStatsSummary: %v", err)
}
if summary.Summary.TotalCalls != 3 {
t.Fatalf("totalCalls = %d, want 3", summary.Summary.TotalCalls)
}
if summary.Summary.SuccessCalls != 1 {
t.Fatalf("successCalls = %d, want 1", summary.Summary.SuccessCalls)
}
if summary.Summary.FailedCalls != 1 {
t.Fatalf("failedCalls = %d, want 1", summary.Summary.FailedCalls)
}
if len(summary.TopTools) != 1 {
t.Fatalf("top tools = %d, want 1", len(summary.TopTools))
}
if summary.TopTools[0].FailedCalls != 1 {
t.Fatalf("top tool failedCalls = %d, want 1", summary.TopTools[0].FailedCalls)
}
}
+6 -2
View File
@@ -84,7 +84,7 @@ func (db *DB) ListConversationsByProjectID(projectID string, limit, offset int)
limit = 100 limit = 100
} }
rows, err := db.Query( rows, err := db.Query(
`SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id `SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id, role_name
FROM conversations WHERE project_id = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?`, FROM conversations WHERE project_id = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?`,
projectID, limit, offset, projectID, limit, offset,
) )
@@ -99,12 +99,16 @@ func (db *DB) ListConversationsByProjectID(projectID string, limit, offset int)
var createdAt, updatedAt string var createdAt, updatedAt string
var pinned int var pinned int
var pid sql.NullString var pid sql.NullString
if err := rows.Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &pid); err != nil { var roleName sql.NullString
if err := rows.Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &pid, &roleName); err != nil {
return nil, err return nil, err
} }
if pid.Valid { if pid.Valid {
conv.ProjectID = strings.TrimSpace(pid.String) conv.ProjectID = strings.TrimSpace(pid.String)
} }
if roleName.Valid {
conv.RoleName = normalizeConversationRoleName(roleName.String)
}
conv.CreatedAt = parseDBTime(createdAt) conv.CreatedAt = parseDBTime(createdAt)
conv.UpdatedAt = parseDBTime(updatedAt) conv.UpdatedAt = parseDBTime(updatedAt)
conv.Pinned = pinned != 0 conv.Pinned = pinned != 0
+611 -16
View File
@@ -1,13 +1,17 @@
package handler package handler
import ( import (
"archive/zip"
"crypto/rand" "crypto/rand"
"encoding/json"
"fmt" "fmt"
"io" "io"
"mime"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"sort" "sort"
"strconv"
"strings" "strings"
"time" "time"
"unicode/utf8" "unicode/utf8"
@@ -21,8 +25,15 @@ import (
) )
const ( const (
chatUploadsRootDirName = "chat_uploads" chatUploadsRootDirName = "chat_uploads"
maxChatUploadEditBytes = 2 * 1024 * 1024 // 文本编辑上限 reductionRootDirName = "tmp/reduction"
artifactsRootDirName = "data/conversation_artifacts"
reductionVirtualPrefix = "__reduction__/"
artifactVirtualPrefix = "__conversation_artifact__/"
chatUploadSourceUpload = "upload"
chatUploadSourceReduction = "reduction"
chatUploadSourceConversation = "conversation_artifact"
maxChatUploadEditBytes = 2 * 1024 * 1024 // 文本编辑上限
) )
// ChatUploadsHandler 对话中上传附件(chat_uploads 目录)的管理 API // ChatUploadsHandler 对话中上传附件(chat_uploads 目录)的管理 API
@@ -66,6 +77,64 @@ func (h *ChatUploadsHandler) pathAllowed(c *gin.Context, relativePath string) bo
return h.db.UserCanAccessResource(session.UserID, session.Scope, "conversation", parts[1]) return h.db.UserCanAccessResource(session.UserID, session.Scope, "conversation", parts[1])
} }
func (h *ChatUploadsHandler) reductionPathAllowed(c *gin.Context, scope, id string) bool {
session, ok := security.CurrentSession(c)
if !ok || h.db == nil {
return false
}
if session.Scope == database.RBACScopeAll {
return true
}
id = strings.TrimSpace(id)
switch scope {
case "conversations":
if id == "" || id == "default" {
return false
}
return h.db.UserCanAccessResource(session.UserID, session.Scope, "conversation", id)
case "projects":
if id == "" {
return false
}
return h.db.UserCanAccessResource(session.UserID, session.Scope, "project", id)
default:
return false
}
}
func (h *ChatUploadsHandler) reductionVirtualPathAllowed(c *gin.Context, relativePath string) bool {
rel := strings.TrimPrefix(strings.TrimSpace(relativePath), reductionVirtualPrefix)
parts := strings.Split(filepath.ToSlash(rel), "/")
if len(parts) < 2 {
return false
}
return h.reductionPathAllowed(c, parts[0], parts[1])
}
func (h *ChatUploadsHandler) conversationArtifactPathAllowed(c *gin.Context, conversationID string) bool {
session, ok := security.CurrentSession(c)
if !ok || h.db == nil {
return false
}
if session.Scope == database.RBACScopeAll {
return true
}
conversationID = strings.TrimSpace(conversationID)
if conversationID == "" || conversationID == "default" {
return false
}
return h.db.UserCanAccessResource(session.UserID, session.Scope, "conversation", conversationID)
}
func (h *ChatUploadsHandler) conversationArtifactVirtualPathAllowed(c *gin.Context, relativePath string) bool {
rel := strings.TrimPrefix(strings.TrimSpace(relativePath), artifactVirtualPrefix)
parts := strings.Split(filepath.ToSlash(rel), "/")
if len(parts) < 1 {
return false
}
return h.conversationArtifactPathAllowed(c, parts[0])
}
func (h *ChatUploadsHandler) absRoot() (string, error) { func (h *ChatUploadsHandler) absRoot() (string, error) {
cwd, err := os.Getwd() cwd, err := os.Getwd()
if err != nil { if err != nil {
@@ -74,6 +143,46 @@ func (h *ChatUploadsHandler) absRoot() (string, error) {
return filepath.Abs(filepath.Join(cwd, chatUploadsRootDirName)) return filepath.Abs(filepath.Join(cwd, chatUploadsRootDirName))
} }
func (h *ChatUploadsHandler) absReductionRoot() (string, error) {
if h.db != nil {
if base := strings.TrimSpace(h.db.EinoReductionBaseDir()); base != "" {
if filepath.IsAbs(base) {
return filepath.Abs(base)
}
cwd, err := os.Getwd()
if err != nil {
return "", err
}
return filepath.Abs(filepath.Join(cwd, base))
}
}
cwd, err := os.Getwd()
if err != nil {
return "", err
}
return filepath.Abs(filepath.Join(cwd, reductionRootDirName))
}
func (h *ChatUploadsHandler) absConversationArtifactsRoot() (string, error) {
if h.db != nil {
if base := strings.TrimSpace(h.db.ConversationArtifactsBaseDir()); base != "" {
if filepath.IsAbs(base) {
return filepath.Abs(base)
}
cwd, err := os.Getwd()
if err != nil {
return "", err
}
return filepath.Abs(filepath.Join(cwd, base))
}
}
cwd, err := os.Getwd()
if err != nil {
return "", err
}
return filepath.Abs(filepath.Join(cwd, artifactsRootDirName))
}
// resolveUnderChatUploads 校验 relativePath(使用 / 分隔)对应文件必须在 chat_uploads 根下 // resolveUnderChatUploads 校验 relativePath(使用 / 分隔)对应文件必须在 chat_uploads 根下
func (h *ChatUploadsHandler) resolveUnderChatUploads(relativePath string) (abs string, err error) { func (h *ChatUploadsHandler) resolveUnderChatUploads(relativePath string) (abs string, err error) {
root, err := h.absRoot() root, err := h.absRoot()
@@ -105,30 +214,44 @@ type ChatUploadFileItem struct {
RelativePath string `json:"relativePath"` RelativePath string `json:"relativePath"`
AbsolutePath string `json:"absolutePath"` // 服务器上的绝对路径,便于在对话中引用(与附件落盘路径一致) AbsolutePath string `json:"absolutePath"` // 服务器上的绝对路径,便于在对话中引用(与附件落盘路径一致)
Name string `json:"name"` Name string `json:"name"`
Source string `json:"source,omitempty"`
Size int64 `json:"size"` Size int64 `json:"size"`
ModifiedUnix int64 `json:"modifiedUnix"` ModifiedUnix int64 `json:"modifiedUnix"`
Date string `json:"date"` Date string `json:"date"`
ConversationID string `json:"conversationId"` ConversationID string `json:"conversationId"`
ProjectID string `json:"projectId,omitempty"`
// SubPath 为日期、会话目录之下的子路径(不含文件名),如 date/conv/a/b/file 则为 "a/b";无嵌套则为 ""。 // SubPath 为日期、会话目录之下的子路径(不含文件名),如 date/conv/a/b/file 则为 "a/b";无嵌套则为 ""。
SubPath string `json:"subPath"` SubPath string `json:"subPath"`
} }
// List GET /api/chat-uploads func (h *ChatUploadsHandler) conversationProjectID(conversationID string, cache map[string]string) string {
func (h *ChatUploadsHandler) List(c *gin.Context) { conversationID = strings.TrimSpace(conversationID)
conversationFilter := strings.TrimSpace(c.Query("conversation")) if conversationID == "" || conversationID == "_manual" || conversationID == "_new" || h.db == nil {
return ""
}
if v, ok := cache[conversationID]; ok {
return v
}
projectID, err := h.db.GetConversationProjectID(conversationID)
if err != nil {
projectID = ""
}
cache[conversationID] = projectID
return projectID
}
func (h *ChatUploadsHandler) collectFiles(c *gin.Context, conversationFilter, projectFilter string) ([]ChatUploadFileItem, []string, error) {
root, err := h.absRoot() root, err := h.absRoot()
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return nil, nil, err
return
} }
// 保证根目录存在,否则「按文件夹」浏览时无法 mkdir,且首次列表为空时界面无路径工具栏 // 保证根目录存在,否则「按文件夹」浏览时无法 mkdir,且首次列表为空时界面无路径工具栏
if err := os.MkdirAll(root, 0755); err != nil { if err := os.MkdirAll(root, 0755); err != nil {
h.logger.Warn("创建 chat_uploads 根目录失败", zap.Error(err)) return nil, nil, err
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
} }
var files []ChatUploadFileItem var files []ChatUploadFileItem
var folders []string var folders []string
projectCache := make(map[string]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
@@ -157,6 +280,7 @@ func (h *ChatUploadsHandler) List(c *gin.Context) {
if len(parts) >= 3 { if len(parts) >= 3 {
convID = parts[1] convID = parts[1]
} }
projectID := h.conversationProjectID(convID, projectCache)
var subPath string var subPath string
if len(parts) >= 4 { if len(parts) >= 4 {
subPath = strings.Join(parts[2:len(parts)-1], "/") subPath = strings.Join(parts[2:len(parts)-1], "/")
@@ -164,29 +288,32 @@ func (h *ChatUploadsHandler) List(c *gin.Context) {
if conversationFilter != "" && convID != conversationFilter { if conversationFilter != "" && convID != conversationFilter {
return nil return nil
} }
if projectFilter != "" && projectID != projectFilter {
return nil
}
absPath, _ := filepath.Abs(path) absPath, _ := filepath.Abs(path)
files = append(files, ChatUploadFileItem{ files = append(files, ChatUploadFileItem{
RelativePath: relSlash, RelativePath: relSlash,
AbsolutePath: absPath, AbsolutePath: absPath,
Name: d.Name(), Name: d.Name(),
Source: chatUploadSourceUpload,
Size: info.Size(), Size: info.Size(),
ModifiedUnix: info.ModTime().Unix(), ModifiedUnix: info.ModTime().Unix(),
Date: dateStr, Date: dateStr,
ConversationID: convID, ConversationID: convID,
ProjectID: projectID,
SubPath: subPath, SubPath: subPath,
}) })
return nil return nil
}) })
if err != nil { if err != nil {
h.logger.Warn("列举对话附件失败", zap.Error(err)) return nil, nil, err
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
} }
if conversationFilter != "" { if conversationFilter != "" || projectFilter != "" {
filteredFolders := make([]string, 0, len(folders)) filteredFolders := make([]string, 0, len(folders))
for _, rel := range folders { for _, rel := range folders {
parts := strings.Split(rel, "/") parts := strings.Split(rel, "/")
if len(parts) >= 2 && parts[1] == conversationFilter { if len(parts) >= 2 && (conversationFilter == "" || parts[1] == conversationFilter) && (projectFilter == "" || h.conversationProjectID(parts[1], projectCache) == projectFilter) {
filteredFolders = append(filteredFolders, rel) filteredFolders = append(filteredFolders, rel)
continue continue
} }
@@ -221,12 +348,480 @@ func (h *ChatUploadsHandler) List(c *gin.Context) {
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, "folders": folders}) reductionFiles, err := h.collectReductionFiles(c, conversationFilter, projectFilter)
if err != nil {
h.logger.Warn("列举 reduction 产物失败", zap.Error(err))
} else if len(reductionFiles) > 0 {
files = append(files, reductionFiles...)
}
artifactFiles, err := h.collectConversationArtifactFiles(c, conversationFilter, projectFilter)
if err != nil {
h.logger.Warn("列举 conversation_artifacts 产物失败", zap.Error(err))
} else if len(artifactFiles) > 0 {
files = append(files, artifactFiles...)
}
sort.Slice(files, func(i, j int) bool {
return files[i].ModifiedUnix > files[j].ModifiedUnix
})
return files, folders, nil
}
func (h *ChatUploadsHandler) collectReductionFiles(c *gin.Context, conversationFilter, projectFilter string) ([]ChatUploadFileItem, error) {
root, err := h.absReductionRoot()
if err != nil {
return nil, err
}
if st, err := os.Stat(root); err != nil || !st.IsDir() {
return nil, nil
}
projectCache := make(map[string]string)
files := make([]ChatUploadFileItem, 0)
for _, scope := range []string{"conversations", "projects"} {
scopeRoot := filepath.Join(root, scope)
_ = filepath.WalkDir(scopeRoot, func(path string, d os.DirEntry, walkErr error) error {
if walkErr != nil || d == nil || d.IsDir() {
return nil
}
rel, err := filepath.Rel(root, path)
if err != nil {
return nil
}
relSlash := filepath.ToSlash(rel)
parts := strings.Split(relSlash, "/")
if len(parts) < 3 {
return nil
}
ownerID := parts[1]
if !h.reductionPathAllowed(c, scope, ownerID) {
return nil
}
var convID, projectID string
if scope == "conversations" {
convID = ownerID
projectID = h.conversationProjectID(convID, projectCache)
} else {
projectID = ownerID
}
if conversationFilter != "" && convID != conversationFilter {
return nil
}
if projectFilter != "" && projectID != projectFilter {
return nil
}
info, err := d.Info()
if err != nil {
return nil
}
name := d.Name()
if filepath.Ext(name) == "" {
name += ".txt"
}
abs, _ := filepath.Abs(path)
files = append(files, ChatUploadFileItem{
RelativePath: reductionVirtualPrefix + relSlash,
AbsolutePath: abs,
Name: name,
Source: chatUploadSourceReduction,
Size: info.Size(),
ModifiedUnix: info.ModTime().Unix(),
Date: info.ModTime().Format("2006-01-02"),
ConversationID: convID,
ProjectID: projectID,
SubPath: strings.Join(parts[2:len(parts)-1], "/"),
})
return nil
})
}
return files, nil
}
func (h *ChatUploadsHandler) collectConversationArtifactFiles(c *gin.Context, conversationFilter, projectFilter string) ([]ChatUploadFileItem, error) {
root, err := h.absConversationArtifactsRoot()
if err != nil {
return nil, err
}
if st, err := os.Stat(root); err != nil || !st.IsDir() {
return nil, nil
}
projectCache := make(map[string]string)
files := make([]ChatUploadFileItem, 0)
_ = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
if walkErr != nil || d == nil || d.IsDir() {
return nil
}
rel, err := filepath.Rel(root, path)
if err != nil {
return nil
}
relSlash := filepath.ToSlash(rel)
parts := strings.Split(relSlash, "/")
if len(parts) < 2 {
return nil
}
convID := parts[0]
if !h.conversationArtifactPathAllowed(c, convID) {
return nil
}
projectID := h.conversationProjectID(convID, projectCache)
if conversationFilter != "" && convID != conversationFilter {
return nil
}
if projectFilter != "" && projectID != projectFilter {
return nil
}
info, err := d.Info()
if err != nil {
return nil
}
name := d.Name()
if filepath.Ext(name) == "" {
name += ".txt"
}
abs, _ := filepath.Abs(path)
files = append(files, ChatUploadFileItem{
RelativePath: artifactVirtualPrefix + relSlash,
AbsolutePath: abs,
Name: name,
Source: chatUploadSourceConversation,
Size: info.Size(),
ModifiedUnix: info.ModTime().Unix(),
Date: info.ModTime().Format("2006-01-02"),
ConversationID: convID,
ProjectID: projectID,
SubPath: strings.Join(parts[1:len(parts)-1], "/"),
})
return nil
})
return files, nil
}
func (h *ChatUploadsHandler) resolveReductionVirtualPath(relativePath string) (string, error) {
rel := strings.TrimPrefix(strings.TrimSpace(relativePath), reductionVirtualPrefix)
rel = filepath.Clean(filepath.FromSlash(rel))
if rel == "." || strings.HasPrefix(rel, "..") {
return "", fmt.Errorf("invalid path")
}
root, err := h.absReductionRoot()
if err != nil {
return "", err
}
full, err := filepath.Abs(filepath.Join(root, rel))
if err != nil {
return "", err
}
rootAbs, _ := filepath.Abs(root)
if full != rootAbs && !strings.HasPrefix(full, rootAbs+string(filepath.Separator)) {
return "", fmt.Errorf("path escapes reduction root")
}
return full, nil
}
func (h *ChatUploadsHandler) resolveConversationArtifactVirtualPath(relativePath string) (string, error) {
rel := strings.TrimPrefix(strings.TrimSpace(relativePath), artifactVirtualPrefix)
rel = filepath.Clean(filepath.FromSlash(rel))
if rel == "." || strings.HasPrefix(rel, "..") {
return "", fmt.Errorf("invalid path")
}
root, err := h.absConversationArtifactsRoot()
if err != nil {
return "", err
}
full, err := filepath.Abs(filepath.Join(root, rel))
if err != nil {
return "", err
}
rootAbs, _ := filepath.Abs(root)
if full != rootAbs && !strings.HasPrefix(full, rootAbs+string(filepath.Separator)) {
return "", fmt.Errorf("path escapes conversation artifacts root")
}
return full, nil
}
func chatUploadItemIsInternal(item ChatUploadFileItem) bool {
return item.Source == chatUploadSourceReduction ||
item.Source == chatUploadSourceConversation ||
strings.HasPrefix(item.RelativePath, reductionVirtualPrefix) ||
strings.HasPrefix(item.RelativePath, artifactVirtualPrefix)
}
func (h *ChatUploadsHandler) resolveListedFilePath(item ChatUploadFileItem) (string, error) {
switch {
case item.Source == chatUploadSourceReduction || strings.HasPrefix(item.RelativePath, reductionVirtualPrefix):
return h.resolveReductionVirtualPath(item.RelativePath)
case item.Source == chatUploadSourceConversation || strings.HasPrefix(item.RelativePath, artifactVirtualPrefix):
return h.resolveConversationArtifactVirtualPath(item.RelativePath)
default:
return h.resolveUnderChatUploads(item.RelativePath)
}
}
func chatUploadSourceMatches(item ChatUploadFileItem, sourceFilter string) bool {
sourceFilter = strings.TrimSpace(sourceFilter)
if sourceFilter == "" || sourceFilter == "all" {
return true
}
source := strings.TrimSpace(item.Source)
if source == "" {
source = chatUploadSourceUpload
}
return source == sourceFilter
}
func chatUploadSearchMatches(item ChatUploadFileItem, search string) bool {
search = strings.ToLower(strings.TrimSpace(search))
if search == "" {
return true
}
values := []string{
item.RelativePath,
item.Name,
item.Source,
item.Date,
item.ConversationID,
item.ProjectID,
item.SubPath,
}
for _, value := range values {
if strings.Contains(strings.ToLower(value), search) {
return true
}
}
return false
}
func filterChatUploadItems(files []ChatUploadFileItem, sourceFilter, search string) []ChatUploadFileItem {
if strings.TrimSpace(sourceFilter) == "" && strings.TrimSpace(search) == "" {
return files
}
out := make([]ChatUploadFileItem, 0, len(files))
for _, item := range files {
if chatUploadSourceMatches(item, sourceFilter) && chatUploadSearchMatches(item, search) {
out = append(out, item)
}
}
return out
}
func parsePositiveIntQuery(c *gin.Context, key string, def, max int) int {
raw := strings.TrimSpace(c.Query(key))
if raw == "" {
return def
}
if strings.EqualFold(raw, "all") {
return 0
}
n, err := strconv.Atoi(raw)
if err != nil || n <= 0 {
return def
}
if max > 0 && n > max {
return max
}
return n
}
func paginateChatUploadItems(files []ChatUploadFileItem, page, pageSize int) []ChatUploadFileItem {
if pageSize <= 0 {
return files
}
if page <= 0 {
page = 1
}
start := (page - 1) * pageSize
if start >= len(files) {
return []ChatUploadFileItem{}
}
end := start + pageSize
if end > len(files) {
end = len(files)
}
return files[start:end]
}
// List GET /api/chat-uploads
func (h *ChatUploadsHandler) List(c *gin.Context) {
conversationFilter := strings.TrimSpace(c.Query("conversation"))
projectFilter := strings.TrimSpace(c.Query("project"))
sourceFilter := strings.TrimSpace(c.Query("source"))
search := strings.TrimSpace(c.Query("search"))
page := parsePositiveIntQuery(c, "page", 1, 0)
pageSize := parsePositiveIntQuery(c, "pageSize", 20, 200)
files, folders, err := h.collectFiles(c, conversationFilter, projectFilter)
if err != nil {
h.logger.Warn("列举对话附件失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
files = filterChatUploadItems(files, sourceFilter, search)
total := len(files)
paged := paginateChatUploadItems(files, page, pageSize)
c.JSON(http.StatusOK, gin.H{
"files": paged,
"folders": folders,
"total": total,
"page": page,
"pageSize": pageSize,
})
}
// Export GET /api/chat-uploads/export?conversation=...&project=...
func (h *ChatUploadsHandler) Export(c *gin.Context) {
conversationFilter := strings.TrimSpace(c.Query("conversation"))
projectFilter := strings.TrimSpace(c.Query("project"))
sourceFilter := strings.TrimSpace(c.Query("source"))
search := strings.TrimSpace(c.Query("search"))
files, _, err := h.collectFiles(c, conversationFilter, projectFilter)
if err != nil {
h.logger.Warn("导出对话附件失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
files = filterChatUploadItems(files, sourceFilter, search)
if len(files) == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "no files to export"})
return
}
nameParts := []string{"chat-files"}
if projectFilter != "" {
nameParts = append(nameParts, "project-"+projectFilter)
}
if conversationFilter != "" {
nameParts = append(nameParts, "conversation-"+conversationFilter)
}
nameParts = append(nameParts, time.Now().Format("20060102-150405"))
filename := strings.Join(nameParts, "-") + ".zip"
c.Header("Content-Type", "application/zip")
c.Header("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": filename}))
zw := zip.NewWriter(c.Writer)
defer zw.Close()
manifest := gin.H{
"exportedAt": time.Now().UTC().Format(time.RFC3339),
"conversationId": conversationFilter,
"projectId": projectFilter,
"source": sourceFilter,
"search": search,
"fileCount": len(files),
"files": files,
"layout": "chat uploads are stored under conversations/<conversationId>/; internal outputs are stored under internal/<source>/",
"sourceDirectory": []string{chatUploadsRootDirName, reductionRootDirName, artifactsRootDirName},
}
manifestBytes, _ := json.MarshalIndent(manifest, "", " ")
mw, err := zw.Create("manifest.json")
if err != nil {
h.logger.Warn("写入附件导出清单失败", zap.Error(err))
return
}
_, _ = mw.Write(manifestBytes)
used := make(map[string]int)
for _, item := range files {
abs, err := h.resolveListedFilePath(item)
if err != nil {
continue
}
st, err := os.Stat(abs)
if err != nil || st.IsDir() {
continue
}
var zipName string
if item.Source == chatUploadSourceReduction || strings.HasPrefix(item.RelativePath, reductionVirtualPrefix) {
rel := strings.TrimPrefix(item.RelativePath, reductionVirtualPrefix)
zipName = filepath.ToSlash(filepath.Join("internal", "reduction", rel))
if filepath.Ext(zipName) == "" {
zipName += ".txt"
}
} else if item.Source == chatUploadSourceConversation || strings.HasPrefix(item.RelativePath, artifactVirtualPrefix) {
rel := strings.TrimPrefix(item.RelativePath, artifactVirtualPrefix)
zipName = filepath.ToSlash(filepath.Join("internal", "conversation_artifacts", rel))
if filepath.Ext(zipName) == "" {
zipName += ".txt"
}
} else {
conv := strings.TrimSpace(item.ConversationID)
if conv == "" || conv == "_manual" || conv == "_new" {
conv = "manual"
}
zipName = filepath.ToSlash(filepath.Join("conversations", conv, strings.TrimSpace(item.SubPath), item.Name))
}
if used[zipName] > 0 {
ext := filepath.Ext(zipName)
zipName = strings.TrimSuffix(zipName, ext) + fmt.Sprintf("-%d", used[zipName]+1) + ext
}
used[zipName]++
fw, err := zw.Create(zipName)
if err != nil {
h.logger.Warn("创建附件导出项失败", zap.String("path", item.RelativePath), zap.Error(err))
continue
}
src, err := os.Open(abs)
if err != nil {
continue
}
_, copyErr := io.Copy(fw, src)
_ = src.Close()
if copyErr != nil {
h.logger.Warn("复制附件导出项失败", zap.String("path", item.RelativePath), zap.Error(copyErr))
return
}
}
if h.audit != nil {
h.audit.RecordOK(c, "file", "export", "导出对话附件", "chat_upload", filename, map[string]interface{}{
"conversation_id": conversationFilter,
"project_id": projectFilter,
"file_count": len(files),
})
}
} }
// Download GET /api/chat-uploads/download?path=... // Download GET /api/chat-uploads/download?path=...
func (h *ChatUploadsHandler) Download(c *gin.Context) { func (h *ChatUploadsHandler) Download(c *gin.Context) {
p := c.Query("path") p := c.Query("path")
if strings.HasPrefix(strings.TrimSpace(p), reductionVirtualPrefix) {
if !h.reductionVirtualPathAllowed(c, p) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
abs, err := h.resolveReductionVirtualPath(p)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
st, err := os.Stat(abs)
if err != nil || st.IsDir() {
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
return
}
name := filepath.Base(abs)
if filepath.Ext(name) == "" {
name += ".txt"
}
c.FileAttachment(abs, name)
return
}
if strings.HasPrefix(strings.TrimSpace(p), artifactVirtualPrefix) {
if !h.conversationArtifactVirtualPathAllowed(c, p) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
abs, err := h.resolveConversationArtifactVirtualPath(p)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
st, err := os.Stat(abs)
if err != nil || st.IsDir() {
c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
return
}
name := filepath.Base(abs)
if filepath.Ext(name) == "" {
name += ".txt"
}
c.FileAttachment(abs, name)
return
}
if !h.pathAllowed(c, p) { if !h.pathAllowed(c, p) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"}) c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return return
+1
View File
@@ -1517,6 +1517,7 @@ func (h *ConfigHandler) ApplyConfig(c *gin.Context) {
h.executor.SetToolOutputMaxBytes(h.config.MultiAgent.EinoMiddleware.ReductionMaxLengthForTruncEffective()) h.executor.SetToolOutputMaxBytes(h.config.MultiAgent.EinoMiddleware.ReductionMaxLengthForTruncEffective())
h.executor.SetToolOutputSpillRoot(h.config.MultiAgent.EinoMiddleware.ReductionRootDir) h.executor.SetToolOutputSpillRoot(h.config.MultiAgent.EinoMiddleware.ReductionRootDir)
h.executor.RegisterTools(h.mcpServer) h.executor.RegisterTools(h.mcpServer)
mcp.RegisterExecutionControlTools(h.mcpServer, h.externalMCPMgr)
// 重新注册漏洞记录工具(内置工具,必须注册) // 重新注册漏洞记录工具(内置工具,必须注册)
if h.vulnerabilityToolRegistrar != nil { if h.vulnerabilityToolRegistrar != nil {
+1 -1
View File
@@ -182,7 +182,7 @@ func summarizeAccessibleExecutionPage(executions []*mcp.ToolExecution, topN int)
func monitorStatusCountsAsFailed(status string) bool { func monitorStatusCountsAsFailed(status string) bool {
switch strings.TrimSpace(strings.ToLower(status)) { switch strings.TrimSpace(strings.ToLower(status)) {
case "failed", "cancelled", "hard_timeout", "orphaned": case "failed", "hard_timeout", "orphaned":
return true return true
default: default:
return false return false
+4
View File
@@ -56,6 +56,7 @@ func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest, c *gin.Context
var err error var err error
meta := audit.ConversationCreateMetaFromGin(c, source) meta := audit.ConversationCreateMetaFromGin(c, source)
meta.ProjectID = projectID meta.ProjectID = projectID
meta.RoleName = req.Role
if webshellID != "" { if webshellID != "" {
meta.Source = source + "_webshell" meta.Source = source + "_webshell"
meta.WebShellConnectionID = webshellID meta.WebShellConnectionID = webshellID
@@ -80,6 +81,9 @@ func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest, c *gin.Context
return nil, fmt.Errorf("无权访问该对话") return nil, fmt.Errorf("无权访问该对话")
} }
} }
if err := h.db.SetConversationRoleName(conversationID, req.Role); err != nil {
h.logger.Warn("更新对话角色失败", zap.String("conversationId", conversationID), zap.String("role", req.Role), zap.Error(err))
}
agentHistoryMessages, err := h.loadHistoryFromAgentTrace(conversationID) agentHistoryMessages, err := h.loadHistoryFromAgentTrace(conversationID)
if err != nil { if err != nil {
+38 -1
View File
@@ -5798,10 +5798,15 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"get": map[string]interface{}{ "get": map[string]interface{}{
"tags": []string{"对话附件"}, "tags": []string{"对话附件"},
"summary": "列出附件", "summary": "列出附件",
"description": "获取对话附件文件列表,可按对话ID过滤。", "description": "获取对话文件列表,包含手动上传附件、工具输出和会话产物,可按会话、项目、来源、文件名搜索和分页过滤。",
"operationId": "listChatUploads", "operationId": "listChatUploads",
"parameters": []map[string]interface{}{ "parameters": []map[string]interface{}{
{"name": "conversation", "in": "query", "required": false, "description": "按对话ID过滤", "schema": map[string]interface{}{"type": "string"}}, {"name": "conversation", "in": "query", "required": false, "description": "按对话ID过滤", "schema": map[string]interface{}{"type": "string"}},
{"name": "project", "in": "query", "required": false, "description": "按项目ID过滤", "schema": map[string]interface{}{"type": "string"}},
{"name": "source", "in": "query", "required": false, "description": "按来源过滤:upload/reduction/conversation_artifact/all", "schema": map[string]interface{}{"type": "string", "enum": []string{"all", "upload", "reduction", "conversation_artifact"}}},
{"name": "search", "in": "query", "required": false, "description": "按文件名或子路径搜索", "schema": map[string]interface{}{"type": "string"}},
{"name": "page", "in": "query", "required": false, "description": "页码,从1开始", "schema": map[string]interface{}{"type": "integer", "default": 1}},
{"name": "pageSize", "in": "query", "required": false, "description": "每页数量,传 all 返回全部", "schema": map[string]interface{}{"oneOf": []map[string]interface{}{{"type": "integer"}, {"type": "string", "enum": []string{"all"}}}}},
}, },
"responses": map[string]interface{}{ "responses": map[string]interface{}{
"200": map[string]interface{}{ "200": map[string]interface{}{
@@ -5823,11 +5828,18 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"modifiedUnix": map[string]interface{}{"type": "integer"}, "modifiedUnix": map[string]interface{}{"type": "integer"},
"date": map[string]interface{}{"type": "string"}, "date": map[string]interface{}{"type": "string"},
"conversationId": map[string]interface{}{"type": "string"}, "conversationId": map[string]interface{}{"type": "string"},
"projectId": map[string]interface{}{"type": "string"},
"subPath": map[string]interface{}{"type": "string"}, "subPath": map[string]interface{}{"type": "string"},
"source": map[string]interface{}{"type": "string", "description": "upload/reduction/conversation_artifact"},
}, },
}, },
}, },
"folders": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}}, "folders": map[string]interface{}{"type": "array", "items": map[string]interface{}{"type": "string"}},
"total": map[string]interface{}{"type": "integer"},
"page": map[string]interface{}{"type": "integer"},
"pageSize": map[string]interface{}{
"type": "integer",
},
}, },
}, },
}, },
@@ -5902,6 +5914,31 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
}, },
}, },
}, },
"/api/chat-uploads/export": map[string]interface{}{
"get": map[string]interface{}{
"tags": []string{"对话附件"},
"summary": "导出附件",
"description": "按当前过滤条件导出对话文件 ZIP,包含 manifest.json。",
"operationId": "exportChatUploads",
"parameters": []map[string]interface{}{
{"name": "conversation", "in": "query", "required": false, "description": "按对话ID过滤", "schema": map[string]interface{}{"type": "string"}},
{"name": "project", "in": "query", "required": false, "description": "按项目ID过滤", "schema": map[string]interface{}{"type": "string"}},
{"name": "source", "in": "query", "required": false, "description": "按来源过滤:upload/reduction/conversation_artifact/all", "schema": map[string]interface{}{"type": "string", "enum": []string{"all", "upload", "reduction", "conversation_artifact"}}},
{"name": "search", "in": "query", "required": false, "description": "按文件名或子路径搜索", "schema": map[string]interface{}{"type": "string"}},
},
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "ZIP文件下载",
"content": map[string]interface{}{
"application/zip": map[string]interface{}{
"schema": map[string]interface{}{"type": "string", "format": "binary"},
},
},
},
"401": map[string]interface{}{"description": "未授权"},
},
},
},
"/api/chat-uploads/download": map[string]interface{}{ "/api/chat-uploads/download": map[string]interface{}{
"get": map[string]interface{}{ "get": map[string]interface{}{
"tags": []string{"对话附件"}, "tags": []string{"对话附件"},
+1 -1
View File
@@ -61,7 +61,7 @@ var apiDocI18nSummaryToKey = map[string]string{
"获取连接状态": "getWebshellConnectionState", "保存连接状态": "saveWebshellConnectionState", "获取连接状态": "getWebshellConnectionState", "保存连接状态": "saveWebshellConnectionState",
"获取AI对话历史": "getWebshellAIHistory", "列出AI对话": "listWebshellAIConversations", "获取AI对话历史": "getWebshellAIHistory", "列出AI对话": "listWebshellAIConversations",
"执行WebShell命令": "webshellExec", "WebShell文件操作": "webshellFileOp", "执行WebShell命令": "webshellExec", "WebShell文件操作": "webshellFileOp",
"列出附件": "listChatUploads", "上传附件": "uploadChatFile", "删除附件": "deleteChatUpload", "列出附件": "listChatUploads", "导出附件": "exportChatUploads", "上传附件": "uploadChatFile", "删除附件": "deleteChatUpload",
"下载附件": "downloadChatUpload", "获取附件文本内容": "getChatUploadContent", "下载附件": "downloadChatUpload", "获取附件文本内容": "getChatUploadContent",
"写入附件文本内容": "putChatUploadContent", "创建附件目录": "mkdirChatUpload", "重命名附件": "renameChatUpload", "写入附件文本内容": "putChatUploadContent", "创建附件目录": "mkdirChatUpload", "重命名附件": "renameChatUpload",
"企业微信回调验证": "wecomCallbackVerify", "企业微信消息回调": "wecomCallbackMessage", "企业微信回调验证": "wecomCallbackVerify", "企业微信消息回调": "wecomCallbackMessage",
+22 -10
View File
@@ -721,15 +721,22 @@ func (h *WebShellHandler) Exec(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "url and command are required"}) c.JSON(http.StatusBadRequest, gin.H{"error": "url and command are required"})
return return
} }
conn, allowed := h.authorizedWebshellConnection(c, req.ConnectionID, req.URL) // Pre-save connectivity tests send form credentials without connection_id.
if !allowed { // Saved connections must go through resource ACL; DB credentials are authoritative.
if cid := strings.TrimSpace(req.ConnectionID); cid != "" {
conn, allowed := h.authorizedWebshellConnection(c, cid, req.URL)
if !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
// Never let a caller pair an authorized ID with attacker-controlled
// transport credentials or a URL.
req.URL, req.Password, req.Type = conn.URL, conn.Password, conn.Type
req.Method, req.CmdParam, req.Encoding = conn.Method, conn.CmdParam, conn.Encoding
} else if !security.SessionHasPermission(c, "webshell:write") {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"}) c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return return
} }
// The database record is authoritative. Never let a caller pair an
// authorized ID with attacker-controlled transport credentials or a URL.
req.URL, req.Password, req.Type = conn.URL, conn.Password, conn.Type
req.Method, req.CmdParam, req.Encoding = conn.Method, conn.CmdParam, conn.Encoding
parsed, err := url.Parse(req.URL) parsed, err := url.Parse(req.URL)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") { if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
@@ -823,13 +830,18 @@ func (h *WebShellHandler) FileOp(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "url and action are required"}) c.JSON(http.StatusBadRequest, gin.H{"error": "url and action are required"})
return return
} }
conn, allowed := h.authorizedWebshellConnection(c, req.ConnectionID, req.URL) if cid := strings.TrimSpace(req.ConnectionID); cid != "" {
if !allowed { conn, allowed := h.authorizedWebshellConnection(c, cid, req.URL)
if !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
req.URL, req.Password, req.Type = conn.URL, conn.Password, conn.Type
req.Method, req.CmdParam, req.Encoding, req.OS = conn.Method, conn.CmdParam, conn.Encoding, conn.OS
} else if !security.SessionHasPermission(c, "webshell:write") {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"}) c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return return
} }
req.URL, req.Password, req.Type = conn.URL, conn.Password, conn.Type
req.Method, req.CmdParam, req.Encoding, req.OS = conn.Method, conn.CmdParam, conn.Encoding, conn.OS
parsed, err := url.Parse(req.URL) parsed, err := url.Parse(req.URL)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") { if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
+35 -3
View File
@@ -40,15 +40,47 @@ func TestWebshellExecRequiresConnectionAccessWhenConnectionIDProvided(t *testing
} }
} }
func TestWebshellExecRejectsAdHocURLWithoutConnectionID(t *testing.T) { func TestWebshellExecAllowsAdHocURLWithoutConnectionID(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
_, user, _, _ := setupWebshellRBACTest(t) _, user, _, _ := setupWebshellRBACTest(t)
handler := NewWebShellHandler(zap.NewNop(), nil) handler := NewWebShellHandler(zap.NewNop(), nil)
// Ad-hoc probe (connectivity test before save) must not be rejected as "无权访问".
// The target URL will fail to connect; we only assert auth allows the request through.
w := performWebshellJSON(user, http.MethodPost, "/api/webshell/exec", map[string]interface{}{ w := performWebshellJSON(user, http.MethodPost, "/api/webshell/exec", map[string]interface{}{
"url": "http://127.0.0.1/admin", "command": "id", "url": "http://127.0.0.1:1/admin", "command": "id",
}, handler.Exec) }, handler.Exec)
if w.Code == http.StatusForbidden {
t.Fatalf("ad-hoc URL status = %d, want non-403: %s", w.Code, w.Body.String())
}
var resp ExecResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v body=%s", err, w.Body.String())
}
if resp.OK {
t.Fatalf("expected connection failure for closed port, got ok=true")
}
}
func TestWebshellExecRejectsAdHocWithoutWritePermission(t *testing.T) {
gin.SetMode(gin.TestMode)
user := &database.RBACUser{ID: "u_ro", Username: "readonly"}
handler := NewWebShellHandler(zap.NewNop(), nil)
payload, _ := json.Marshal(map[string]interface{}{
"url": "http://127.0.0.1/admin", "command": "id",
})
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/api/webshell/exec", bytes.NewReader(payload))
c.Request.Header.Set("Content-Type", "application/json")
c.Set(security.ContextSessionKey, security.Session{
UserID: user.ID,
Username: user.Username,
Permissions: map[string]bool{"webshell:read": true},
Scope: database.RBACScopeAssigned,
})
handler.Exec(c)
if w.Code != http.StatusForbidden { if w.Code != http.StatusForbidden {
t.Fatalf("ad-hoc URL status = %d, want %d: %s", w.Code, http.StatusForbidden, w.Body.String()) t.Fatalf("status = %d, want %d: %s", w.Code, http.StatusForbidden, w.Body.String())
} }
} }
+78 -6
View File
@@ -15,6 +15,8 @@ import (
const ( const (
defaultExecutionWaitTimeout = 60 * time.Second defaultExecutionWaitTimeout = 60 * time.Second
maxExecutionWaitTimeout = 10 * time.Minute maxExecutionWaitTimeout = 10 * time.Minute
defaultPartialPreviewBytes = 4096
maxPartialPreviewBytes = 64 * 1024
) )
// RegisterExecutionControlTools exposes execution handle operations to Eino as // RegisterExecutionControlTools exposes execution handle operations to Eino as
@@ -32,7 +34,9 @@ func RegisterExecutionControlTools(server *Server, external *ExternalMCPManager)
InputSchema: map[string]interface{}{ InputSchema: map[string]interface{}{
"type": "object", "type": "object",
"properties": map[string]interface{}{ "properties": map[string]interface{}{
"execution_id": map[string]interface{}{"type": "string", "description": "工具执行 ID"}, "execution_id": map[string]interface{}{"type": "string", "description": "工具执行 ID"},
"include_partial_output": map[string]interface{}{"type": "boolean", "description": "是否返回运行中已产生输出的尾部预览,默认 true"},
"partial_output_max_bytes": map[string]interface{}{"type": "number", "description": "partial_output 最多返回字节数,默认 4096,最大 65536"},
}, },
"required": []string{"execution_id"}, "required": []string{"execution_id"},
}, },
@@ -45,7 +49,7 @@ func RegisterExecutionControlTools(server *Server, external *ExternalMCPManager)
if exec == nil { if exec == nil {
return textToolResult("未找到该 execution_id: "+id, true), nil return textToolResult("未找到该 execution_id: "+id, true), nil
} }
return textToolResult(formatExecutionForModel(exec), false), nil return textToolResult(formatExecutionForModel(exec, executionFormatOptionsFromArgs(args)), false), nil
}) })
server.RegisterTool(Tool{ server.RegisterTool(Tool{
@@ -55,8 +59,10 @@ func RegisterExecutionControlTools(server *Server, external *ExternalMCPManager)
InputSchema: map[string]interface{}{ InputSchema: map[string]interface{}{
"type": "object", "type": "object",
"properties": map[string]interface{}{ "properties": map[string]interface{}{
"execution_id": map[string]interface{}{"type": "string", "description": "工具执行 ID"}, "execution_id": map[string]interface{}{"type": "string", "description": "工具执行 ID"},
"timeout_seconds": map[string]interface{}{"type": "number", "description": "本次最多等待秒数,默认 60,最大 600"}, "timeout_seconds": map[string]interface{}{"type": "number", "description": "本次最多等待秒数,默认 60,最大 600"},
"include_partial_output": map[string]interface{}{"type": "boolean", "description": "是否返回运行中已产生输出的尾部预览,默认 true"},
"partial_output_max_bytes": map[string]interface{}{"type": "number", "description": "partial_output 最多返回字节数,默认 4096,最大 65536"},
}, },
"required": []string{"execution_id"}, "required": []string{"execution_id"},
}, },
@@ -73,7 +79,7 @@ func RegisterExecutionControlTools(server *Server, external *ExternalMCPManager)
if snap == nil || snap.Execution == nil { if snap == nil || snap.Execution == nil {
return textToolResult("未找到该 execution_id: "+id, true), nil return textToolResult("未找到该 execution_id: "+id, true), nil
} }
body := formatExecutionForModel(snap.Execution) body := formatExecutionForModel(snap.Execution, executionFormatOptionsFromArgs(args))
if errors.Is(err, ErrExecutionWaitTimeout) { if errors.Is(err, ErrExecutionWaitTimeout) {
body += "\n\n本次等待已到达 timeout_seconds,上述 execution 仍未完成。可继续等待、取消,或采用其他步骤。" body += "\n\n本次等待已到达 timeout_seconds,上述 execution 仍未完成。可继续等待、取消,或采用其他步骤。"
} }
@@ -144,7 +150,25 @@ func lookupToolExecution(server *Server, external *ExternalMCPManager, id string
return nil return nil
} }
func formatExecutionForModel(exec *ToolExecution) string { type executionFormatOptions struct {
includePartialOutput bool
partialMaxBytes int
}
func executionFormatOptionsFromArgs(args map[string]interface{}) executionFormatOptions {
includePartial := true
if raw, ok := args["include_partial_output"]; ok {
if b, ok := raw.(bool); ok {
includePartial = b
} else if s := strings.TrimSpace(fmt.Sprint(raw)); s != "" {
includePartial = strings.EqualFold(s, "true") || s == "1" || strings.EqualFold(s, "yes")
}
}
maxBytes := intArg(args, "partial_output_max_bytes", defaultPartialPreviewBytes, maxPartialPreviewBytes)
return executionFormatOptions{includePartialOutput: includePartial, partialMaxBytes: maxBytes}
}
func formatExecutionForModel(exec *ToolExecution, opts executionFormatOptions) string {
if exec == nil { if exec == nil {
return "execution: null" return "execution: null"
} }
@@ -167,6 +191,15 @@ func formatExecutionForModel(exec *ToolExecution) string {
payload["result"] = ToolResultPlainText(exec.Result) payload["result"] = ToolResultPlainText(exec.Result)
payload["is_error"] = exec.Result.IsError payload["is_error"] = exec.Result.IsError
} }
if opts.includePartialOutput && exec.PartialOutput != "" {
partial := tailStringBytes(exec.PartialOutput, opts.partialMaxBytes)
payload["partial_output"] = partial
payload["partial_output_bytes"] = exec.PartialOutputBytes
payload["partial_output_truncated"] = exec.PartialOutputTruncated || len([]byte(partial)) < len([]byte(exec.PartialOutput))
if exec.PartialOutputUpdatedAt != nil {
payload["partial_output_updated_at"] = exec.PartialOutputUpdatedAt.Format(time.RFC3339)
}
}
b, err := json.MarshalIndent(payload, "", " ") b, err := json.MarshalIndent(payload, "", " ")
if err != nil { if err != nil {
return fmt.Sprintf("execution_id: %s\nstatus: %s\nerror: %s", exec.ID, exec.Status, exec.Error) return fmt.Sprintf("execution_id: %s\nstatus: %s\nerror: %s", exec.ID, exec.Status, exec.Error)
@@ -174,6 +207,17 @@ func formatExecutionForModel(exec *ToolExecution) string {
return string(b) return string(b)
} }
func tailStringBytes(s string, maxBytes int) string {
if maxBytes <= 0 {
maxBytes = defaultPartialPreviewBytes
}
b := []byte(s)
if len(b) <= maxBytes {
return s
}
return string(b[len(b)-maxBytes:])
}
func textToolResult(text string, isErr bool) *ToolResult { func textToolResult(text string, isErr bool) *ToolResult {
return &ToolResult{Content: []Content{{Type: "text", Text: text}}, IsError: isErr} return &ToolResult{Content: []Content{{Type: "text", Text: text}}, IsError: isErr}
} }
@@ -222,3 +266,31 @@ func durationSecondsArg(args map[string]interface{}, key string, def, max time.D
} }
return d return d
} }
func intArg(args map[string]interface{}, key string, def, max int) int {
if args == nil {
return def
}
var n int
switch v := args[key].(type) {
case int:
n = v
case int64:
n = int(v)
case float64:
n = int(v)
case json.Number:
i, _ := v.Int64()
n = int(i)
case string:
i, _ := strconv.Atoi(strings.TrimSpace(v))
n = i
}
if n <= 0 {
return def
}
if max > 0 && n > max {
return max
}
return n
}
+39
View File
@@ -348,6 +348,22 @@ func (s *ExecutionService) Get(executionID string) (*ExecutionSnapshot, error) {
return s.getPersistedSnapshot(executionID) return s.getPersistedSnapshot(executionID)
} }
func (s *ExecutionService) AppendPartialOutput(executionID, chunk string) bool {
id := strings.TrimSpace(executionID)
if s == nil || id == "" || chunk == "" {
return false
}
now := time.Now()
s.mu.Lock()
defer s.mu.Unlock()
entry := s.entries[id]
if entry == nil || entry.exec == nil {
return false
}
appendPartialOutput(entry.exec, chunk, defaultPartialOutputMaxBytes, now)
return true
}
func (s *ExecutionService) Cancel(executionID, note string) bool { func (s *ExecutionService) Cancel(executionID, note string) bool {
id := strings.TrimSpace(executionID) id := strings.TrimSpace(executionID)
if id == "" || s == nil { if id == "" || s == nil {
@@ -582,5 +598,28 @@ func cloneToolExecution(in *ToolExecution) *ToolExecution {
t := *in.EndTime t := *in.EndTime
out.EndTime = &t out.EndTime = &t
} }
if in.PartialOutputUpdatedAt != nil {
t := *in.PartialOutputUpdatedAt
out.PartialOutputUpdatedAt = &t
}
return &out return &out
} }
func appendPartialOutput(exec *ToolExecution, chunk string, maxBytes int, updatedAt time.Time) {
if exec == nil || chunk == "" {
return
}
if maxBytes <= 0 {
maxBytes = defaultPartialOutputMaxBytes
}
exec.PartialOutputBytes += int64(len([]byte(chunk)))
combined := exec.PartialOutput + chunk
if len([]byte(combined)) > maxBytes {
b := []byte(combined)
combined = string(b[len(b)-maxBytes:])
exec.PartialOutputTruncated = true
}
exec.PartialOutput = combined
t := updatedAt
exec.PartialOutputUpdatedAt = &t
}
+1 -1
View File
@@ -745,7 +745,7 @@ func (m *ExternalMCPManager) CallTool(ctx context.Context, toolName string, args
return result, callErr return result, callErr
}, },
OnDone: func(exec *ToolExecution) { OnDone: func(exec *ToolExecution) {
failed := exec != nil && exec.Status != ToolExecutionStatusCompleted failed := exec != nil && exec.Status != ToolExecutionStatusCompleted && exec.Status != ToolExecutionStatusCancelled
m.recordExternalMCPResult(mcpName, failed) m.recordExternalMCPResult(mcpName, failed)
m.updateStats(toolName, failed) m.updateStats(toolName, failed)
}, },
+42 -3
View File
@@ -59,6 +59,8 @@ type Server struct {
spillRootDir string spillRootDir string
} }
const defaultPartialOutputMaxBytes = 64 * 1024
// SetToolAuthorizer installs the common policy decision point for every // SetToolAuthorizer installs the common policy decision point for every
// user-attributed tool call, whether it originates from HTTP or an Agent. // user-attributed tool call, whether it originates from HTTP or an Agent.
func (s *Server) SetToolAuthorizer(authorizer func(context.Context, string, map[string]interface{}) error) { func (s *Server) SetToolAuthorizer(authorizer func(context.Context, string, map[string]interface{}) error) {
@@ -602,13 +604,13 @@ func (s *Server) handleCallTool(requestCtx context.Context, msg *Message) *Messa
st, msg := executionStatusAndMessage(err) st, msg := executionStatusAndMessage(err)
execution.Status = st execution.Status = st
execution.Error = msg execution.Error = msg
failed = true failed = st != "cancelled"
} else if result != nil && result.IsError { } else if result != nil && result.IsError {
if cancelledWithUserNote { if cancelledWithUserNote {
execution.Status = "cancelled" execution.Status = "cancelled"
execution.Error = "" execution.Error = ""
execution.Result = result execution.Result = result
failed = true failed = false
} else { } else {
execution.Status = "failed" execution.Status = "failed"
if len(result.Content) > 0 { if len(result.Content) > 0 {
@@ -930,7 +932,7 @@ func (s *Server) CallTool(ctx context.Context, toolName string, args map[string]
return handler(runCtx, args) return handler(runCtx, args)
}, },
OnDone: func(exec *ToolExecution) { OnDone: func(exec *ToolExecution) {
failed := exec != nil && exec.Status != ToolExecutionStatusCompleted failed := exec != nil && exec.Status != ToolExecutionStatusCompleted && exec.Status != ToolExecutionStatusCancelled
s.updateStats(toolName, failed) s.updateStats(toolName, failed)
}, },
}) })
@@ -1123,6 +1125,25 @@ func (s *Server) FinishToolExecution(ctx context.Context, executionID, toolName
return id return id
} }
// AppendToolExecutionPartialOutput records a bounded tail preview for a running local execution.
// The final Result remains authoritative and is written only when the tool finishes.
func (s *Server) AppendToolExecutionPartialOutput(executionID, chunk string) {
if s == nil || strings.TrimSpace(executionID) == "" || chunk == "" {
return
}
id := strings.TrimSpace(executionID)
if s.executionService != nil && s.executionService.AppendPartialOutput(id, chunk) {
return
}
now := time.Now()
s.mu.Lock()
exec := s.executions[id]
if exec != nil {
appendPartialOutput(exec, chunk, defaultPartialOutputMaxBytes, now)
}
s.mu.Unlock()
}
// RecordCompletedToolInvocation 将已在其它路径完成的工具调用写入监控存储(格式与 CallTool 结束后一致), // RecordCompletedToolInvocation 将已在其它路径完成的工具调用写入监控存储(格式与 CallTool 结束后一致),
// 用于 Eino ADK filesystem execute 等未经过 CallTool 的场景;返回 executionId 供助手消息 mcpExecutionIds 关联。 // 用于 Eino ADK filesystem execute 等未经过 CallTool 的场景;返回 executionId 供助手消息 mcpExecutionIds 关联。
func (s *Server) RecordCompletedToolInvocation(ctx context.Context, toolName string, args map[string]interface{}, resultText string, invokeErr error) string { func (s *Server) RecordCompletedToolInvocation(ctx context.Context, toolName string, args map[string]interface{}, resultText string, invokeErr error) string {
@@ -1206,6 +1227,24 @@ func (s *Server) unregisterRunningCancel(id string) {
s.runningCancelsMu.Unlock() s.runningCancelsMu.Unlock()
} }
// RegisterToolExecutionCancel lets non-ExecutionService tool paths, such as Eino
// filesystem execute, participate in cancel_tool_execution by execution_id.
func (s *Server) RegisterToolExecutionCancel(id string, cancel context.CancelFunc) {
id = strings.TrimSpace(id)
if s == nil || id == "" || cancel == nil {
return
}
s.registerRunningCancel(id, cancel)
}
func (s *Server) UnregisterToolExecutionCancel(id string) {
id = strings.TrimSpace(id)
if s == nil || id == "" {
return
}
s.unregisterRunningCancel(id)
}
func (s *Server) readAbortUserNote(id string) string { func (s *Server) readAbortUserNote(id string) string {
s.runningCancelsMu.Lock() s.runningCancelsMu.Lock()
defer s.runningCancelsMu.Unlock() defer s.runningCancelsMu.Unlock()
+38
View File
@@ -180,3 +180,41 @@ func TestWaitToolExecutionTimeoutIsObservationNotFailure(t *testing.T) {
} }
close(release) close(release)
} }
func TestGetToolExecutionIncludesBoundedPartialOutput(t *testing.T) {
server := NewServer(zap.NewNop())
RegisterExecutionControlTools(server, nil)
executionID := server.BeginToolExecution(context.Background(), "execute", map[string]interface{}{"command": "demo"})
if executionID == "" {
t.Fatal("missing execution id")
}
server.AppendToolExecutionPartialOutput(executionID, "first\n")
server.AppendToolExecutionPartialOutput(executionID, strings.Repeat("x", 32))
result, _, err := server.CallTool(context.Background(), "get_tool_execution", map[string]interface{}{
"execution_id": executionID,
"partial_output_max_bytes": 8,
})
if err != nil {
t.Fatalf("get_tool_execution: %v", err)
}
body := ToolResultPlainText(result)
if !strings.Contains(body, `"partial_output": "xxxxxxxx"`) {
t.Fatalf("missing bounded partial output: %s", body)
}
if !strings.Contains(body, `"partial_output_bytes": 38`) {
t.Fatalf("missing partial byte count: %s", body)
}
result, _, err = server.CallTool(context.Background(), "get_tool_execution", map[string]interface{}{
"execution_id": executionID,
"include_partial_output": false,
})
if err != nil {
t.Fatalf("get_tool_execution without partial: %v", err)
}
if body := ToolResultPlainText(result); strings.Contains(body, "partial_output") {
t.Fatalf("partial output should be omitted: %s", body)
}
}
+6
View File
@@ -199,6 +199,12 @@ type ToolExecution struct {
StartTime time.Time `json:"startTime"` StartTime time.Time `json:"startTime"`
EndTime *time.Time `json:"endTime,omitempty"` EndTime *time.Time `json:"endTime,omitempty"`
Duration time.Duration `json:"duration,omitempty"` Duration time.Duration `json:"duration,omitempty"`
// PartialOutput is a bounded tail preview of output produced by a running tool.
// It is intentionally separate from Result, which remains the final canonical tool result.
PartialOutput string `json:"partialOutput,omitempty"`
PartialOutputBytes int64 `json:"partialOutputBytes,omitempty"`
PartialOutputTruncated bool `json:"partialOutputTruncated,omitempty"`
PartialOutputUpdatedAt *time.Time `json:"partialOutputUpdatedAt,omitempty"`
// ConversationID 仅 API 展示用(进行中的 Agent 任务),不写入 tool_executions 表。 // ConversationID 仅 API 展示用(进行中的 Agent 任务),不写入 tool_executions 表。
ConversationID string `json:"conversationId,omitempty"` ConversationID string `json:"conversationId,omitempty"`
OwnerUserID string `json:"-"` OwnerUserID string `json:"-"`
+22 -1
View File
@@ -12,6 +12,9 @@ import (
// 与 CallTool 路径一致,使监控页能展示「执行中」状态。 // 与 CallTool 路径一致,使监控页能展示「执行中」状态。
func newEinoExecuteMonitorCallbacks(ctx context.Context, ag *agent.Agent, recorder einomcp.ExecutionRecorder) ( func newEinoExecuteMonitorCallbacks(ctx context.Context, ag *agent.Agent, recorder einomcp.ExecutionRecorder) (
begin func(toolCallID, command string) string, begin func(toolCallID, command string) string,
appendPartial func(executionID, toolCallID, chunk string),
registerCancel func(executionID string, cancel context.CancelFunc),
unregisterCancel func(executionID string),
finish func(executionID, toolCallID, command, stdout string, success bool, invokeErr error), finish func(executionID, toolCallID, command, stdout string, success bool, invokeErr error),
) { ) {
begin = func(toolCallID, command string) string { begin = func(toolCallID, command string) string {
@@ -25,6 +28,24 @@ func newEinoExecuteMonitorCallbacks(ctx context.Context, ag *agent.Agent, record
} }
return id return id
} }
appendPartial = func(executionID, toolCallID, chunk string) {
if ag == nil || executionID == "" || chunk == "" {
return
}
ag.AppendLocalToolExecutionPartialOutput(executionID, chunk)
}
registerCancel = func(executionID string, cancel context.CancelFunc) {
if ag == nil || executionID == "" || cancel == nil {
return
}
ag.RegisterLocalToolExecutionCancel(executionID, cancel)
}
unregisterCancel = func(executionID string) {
if ag == nil || executionID == "" {
return
}
ag.UnregisterLocalToolExecutionCancel(executionID)
}
finish = func(executionID, toolCallID, command, stdout string, success bool, invokeErr error) { finish = func(executionID, toolCallID, command, stdout string, success bool, invokeErr error) {
if ag == nil { if ag == nil {
return return
@@ -43,5 +64,5 @@ func newEinoExecuteMonitorCallbacks(ctx context.Context, ag *agent.Agent, record
recorder(id, toolCallID) recorder(id, toolCallID)
} }
} }
return begin, finish return begin, appendPartial, registerCancel, unregisterCancel, finish
} }
@@ -63,11 +63,16 @@ type einoStreamingShellWrap struct {
outputChunk func(toolName, toolCallID, chunk string) outputChunk func(toolName, toolCallID, chunk string)
// toolTimeoutMinutes 与 agent.tool_timeout_minutes 对齐;>0 时对单次 execute 套用 context 超时(与 MCP 工具经 executeToolViaMCP 行为一致)。0 表示仅依赖上层 ctx(如整任务 10h 上限)。 // toolTimeoutMinutes 与 agent.tool_timeout_minutes 对齐;>0 时对单次 execute 套用 context 超时(与 MCP 工具经 executeToolViaMCP 行为一致)。0 表示仅依赖上层 ctx(如整任务 10h 上限)。
toolTimeoutMinutes int toolTimeoutMinutes int
// toolWaitTimeoutSeconds 与 agent.tool_wait_timeout_seconds 对齐;>0 时本轮等待到期后返回 execution_idshell 继续后台运行。
toolWaitTimeoutSeconds int
// shellNoOutputTimeoutSec:无任何输出时的空闲秒数;0=关闭。 // shellNoOutputTimeoutSec:无任何输出时的空闲秒数;0=关闭。
shellNoOutputTimeoutSec int shellNoOutputTimeoutSec int
// beginMonitor 在 execute 开始时写入 running 状态;finishMonitor 在流结束后更新为 completed/failed。 // beginMonitor 在 execute 开始时写入 running 状态;finishMonitor 在流结束后更新为 completed/failed。
beginMonitor func(toolCallID, command string) string beginMonitor func(toolCallID, command string) string
finishMonitor func(executionID, toolCallID, command, stdout string, success bool, invokeErr error) appendPartialMonitor func(executionID, toolCallID, chunk string)
registerCancelMonitor func(executionID string, cancel context.CancelFunc)
unregisterCancelMonitor func(executionID string)
finishMonitor func(executionID, toolCallID, command, stdout string, success bool, invokeErr error)
} }
func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *filesystem.ExecuteRequest) (*schema.StreamReader[*filesystem.ExecuteResponse], error) { func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *filesystem.ExecuteRequest) (*schema.StreamReader[*filesystem.ExecuteResponse], error) {
@@ -104,6 +109,9 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
if w.toolTimeoutMinutes > 0 { if w.toolTimeoutMinutes > 0 {
execCtx, timeoutCancel = context.WithTimeout(execCtx, time.Duration(w.toolTimeoutMinutes)*time.Minute) execCtx, timeoutCancel = context.WithTimeout(execCtx, time.Duration(w.toolTimeoutMinutes)*time.Minute)
} }
if monitorExecID != "" && w.registerCancelMonitor != nil {
w.registerCancelMonitor(monitorExecID, execCancel)
}
if execReg != nil && convID != "" { if execReg != nil && convID != "" {
execReg.RegisterActiveEinoExecute(convID, execCancel) execReg.RegisterActiveEinoExecute(convID, execCancel)
} }
@@ -116,6 +124,9 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
if execCancel != nil { if execCancel != nil {
execCancel() execCancel()
} }
if monitorExecID != "" && w.unregisterCancelMonitor != nil {
w.unregisterCancelMonitor(monitorExecID)
}
if einoExecuteRecvErrIsToolTimeout(err, execCtx) { if einoExecuteRecvErrIsToolTimeout(err, execCtx) {
hint := "\n\n" + einoExecuteTimeoutUserHint() + "\n" hint := "\n\n" + einoExecuteTimeoutUserHint() + "\n"
if w.finishMonitor != nil { if w.finishMonitor != nil {
@@ -146,7 +157,7 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
outR, outW := schema.Pipe[*filesystem.ExecuteResponse](32) outR, outW := schema.Pipe[*filesystem.ExecuteResponse](32)
go func(inner *schema.StreamReader[*filesystem.ExecuteResponse], command string, cancel context.CancelFunc, timeoutCleanup context.CancelFunc, tctx context.Context, conversationID string, reg mcp.EinoExecuteRunRegistry, toolReg mcp.ToolRunRegistry, execID string, toolCallID string, noOutputSec int) { go func(inner *schema.StreamReader[*filesystem.ExecuteResponse], command string, cancel context.CancelFunc, timeoutCleanup context.CancelFunc, tctx context.Context, conversationID string, reg mcp.EinoExecuteRunRegistry, toolReg mcp.ToolRunRegistry, execID string, toolCallID string, noOutputSec int, waitTimeoutSec int) {
var innerCloseOnce sync.Once var innerCloseOnce sync.Once
closeInner := func() { closeInner := func() {
innerCloseOnce.Do(func() { inner.Close() }) innerCloseOnce.Do(func() { inner.Close() })
@@ -164,6 +175,9 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
if toolReg != nil && conversationID != "" && execID != "" { if toolReg != nil && conversationID != "" && execID != "" {
defer toolReg.UnregisterRunningTool(conversationID, execID) defer toolReg.UnregisterRunningTool(conversationID, execID)
} }
if w.unregisterCancelMonitor != nil && execID != "" {
defer w.unregisterCancelMonitor(execID)
}
// ctx 取消时关闭内层流,避免 amass 等长时间无换行输出时 Recv 永久阻塞。 // ctx 取消时关闭内层流,避免 amass 等长时间无换行输出时 Recv 永久阻塞。
stopWatch := make(chan struct{}) stopWatch := make(chan struct{})
@@ -181,11 +195,30 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
var invokeErr error var invokeErr error
exitCode := 0 exitCode := 0
hasExitCode := false hasExitCode := false
softReturned := false
var outCloseOnce sync.Once
closeOut := func() {
outCloseOnce.Do(func() { outW.Close() })
}
defer closeOut()
sendOut := func(resp *filesystem.ExecuteResponse, err error) bool {
if softReturned {
return false
}
return outW.Send(resp, err)
}
idleWatch := security.NewShellInactivityWatch(noOutputSec) idleWatch := security.NewShellInactivityWatch(noOutputSec)
if idleWatch != nil { if idleWatch != nil {
defer idleWatch.Stop() defer idleWatch.Stop()
} }
var waitTimeoutCh <-chan time.Time
var waitTimer *time.Timer
if waitTimeoutSec > 0 {
waitTimer = time.NewTimer(time.Duration(waitTimeoutSec) * time.Second)
waitTimeoutCh = waitTimer.C
defer waitTimer.Stop()
}
type execRecvMsg struct { type execRecvMsg struct {
resp *filesystem.ExecuteResponse resp *filesystem.ExecuteResponse
@@ -206,8 +239,11 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
success = false success = false
invokeErr = fmt.Errorf("shell inactivity timeout (%ds)", idleWatch.Sec) invokeErr = fmt.Errorf("shell inactivity timeout (%ds)", idleWatch.Sec)
msg := security.ShellNoOutputTimeoutMessage(idleWatch.Sec) msg := security.ShellNoOutputTimeoutMessage(idleWatch.Sec)
_ = outW.Send(&filesystem.ExecuteResponse{Output: msg}, nil) _ = sendOut(&filesystem.ExecuteResponse{Output: msg}, nil)
sb.WriteString(msg) sb.WriteString(msg)
if w.appendPartialMonitor != nil && execID != "" {
w.appendPartialMonitor(execID, toolCallID, msg)
}
if w.outputChunk != nil && toolCallID != "" { if w.outputChunk != nil && toolCallID != "" {
w.outputChunk("execute", toolCallID, msg) w.outputChunk("execute", toolCallID, msg)
} }
@@ -227,6 +263,14 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
case <-idleCh: case <-idleCh:
fireInactivityTimeout() fireInactivityTimeout()
break recvLoop break recvLoop
case <-waitTimeoutCh:
if execID != "" && !softReturned {
msg := einoExecuteSoftWaitTimeoutResult(execID, waitTimeoutSec)
_ = outW.Send(&filesystem.ExecuteResponse{Output: msg}, nil)
softReturned = true
closeOut()
}
waitTimeoutCh = nil
case msg := <-recvCh: case msg := <-recvCh:
rerr := msg.err rerr := msg.err
resp := msg.resp resp := msg.resp
@@ -244,7 +288,7 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
invokeErr = context.Canceled invokeErr = context.Canceled
break recvLoop break recvLoop
} }
_ = outW.Send(nil, rerr) _ = sendOut(nil, rerr)
break recvLoop break recvLoop
} }
if resp != nil { if resp != nil {
@@ -263,11 +307,14 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
} }
sb.WriteString(resp.Output) sb.WriteString(resp.Output)
appended = resp.Output appended = resp.Output
if w.appendPartialMonitor != nil && execID != "" {
w.appendPartialMonitor(execID, toolCallID, appended)
}
} }
if w.outputChunk != nil && strings.TrimSpace(appended) != "" { if w.outputChunk != nil && strings.TrimSpace(appended) != "" {
w.outputChunk("execute", toolCallID, appended) w.outputChunk("execute", toolCallID, appended)
} }
if outW.Send(resp, nil) { if sendOut(resp, nil) {
success = false success = false
invokeErr = fmt.Errorf("execute stream closed by consumer") invokeErr = fmt.Errorf("execute stream closed by consumer")
break recvLoop break recvLoop
@@ -304,7 +351,10 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
// ADK 从本 Pipe 拼出 tool 消息正文;仅 Notify 尾标不会进入模型上下文。超时句写入流,与 UI 一致。 // ADK 从本 Pipe 拼出 tool 消息正文;仅 Notify 尾标不会进入模型上下文。超时句写入流,与 UI 一致。
if invokeErr != nil && errors.Is(invokeErr, context.DeadlineExceeded) { if invokeErr != nil && errors.Is(invokeErr, context.DeadlineExceeded) {
hint := "\n\n" + einoExecuteTimeoutUserHint() + "\n" hint := "\n\n" + einoExecuteTimeoutUserHint() + "\n"
_ = outW.Send(&filesystem.ExecuteResponse{Output: hint}, nil) _ = sendOut(&filesystem.ExecuteResponse{Output: hint}, nil)
if w.appendPartialMonitor != nil && execID != "" {
w.appendPartialMonitor(execID, toolCallID, hint)
}
if w.outputChunk != nil && tid != "" { if w.outputChunk != nil && tid != "" {
w.outputChunk("execute", tid, hint) w.outputChunk("execute", tid, hint)
} }
@@ -313,9 +363,9 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
// 中断时循环内已逐行写入 stdout;此处只追加 USER INTERRUPT NOTE,避免整段输出重复。 // 中断时循环内已逐行写入 stdout;此处只追加 USER INTERRUPT NOTE,避免整段输出重复。
if invokeErr != nil && errors.Is(invokeErr, context.Canceled) && abortNote != "" { if invokeErr != nil && errors.Is(invokeErr, context.Canceled) && abortNote != "" {
if partialStreamed != "" { if partialStreamed != "" {
_ = outW.Send(&filesystem.ExecuteResponse{Output: "\n\n" + mcp.AbortNoteBannerForModel + "\n" + abortNote}, nil) _ = sendOut(&filesystem.ExecuteResponse{Output: "\n\n" + mcp.AbortNoteBannerForModel + "\n" + abortNote}, nil)
} else if text := strings.TrimSpace(sb.String()); text != "" { } else if text := strings.TrimSpace(sb.String()); text != "" {
_ = outW.Send(&filesystem.ExecuteResponse{Output: text + "\n"}, nil) _ = sendOut(&filesystem.ExecuteResponse{Output: text + "\n"}, nil)
} }
} }
rawOutput := sb.String() rawOutput := sb.String()
@@ -323,7 +373,10 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
if !success && hasExitCode && exitCode != 0 { if !success && hasExitCode && exitCode != 0 {
statusLine := security.ExecuteFailureStatusLine(exitCode) statusLine := security.ExecuteFailureStatusLine(exitCode)
if !strings.Contains(rawOutput, "命令执行失败:") { if !strings.Contains(rawOutput, "命令执行失败:") {
_ = outW.Send(&filesystem.ExecuteResponse{Output: statusLine}, nil) _ = sendOut(&filesystem.ExecuteResponse{Output: statusLine}, nil)
if w.appendPartialMonitor != nil && execID != "" {
w.appendPartialMonitor(execID, toolCallID, statusLine)
}
sb.WriteString(statusLine) sb.WriteString(statusLine)
} }
fireBody = einomcp.ToolErrorPrefix + security.FormatCommandFailureResult(exitCode, rawOutput) fireBody = einomcp.ToolErrorPrefix + security.FormatCommandFailureResult(exitCode, rawOutput)
@@ -332,10 +385,25 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
w.finishMonitor(execID, toolCallID, command, sb.String(), success, invokeErr) w.finishMonitor(execID, toolCallID, command, sb.String(), success, invokeErr)
} }
if w.invokeNotify != nil { if w.invokeNotify != nil {
w.invokeNotify.Fire(toolCallID, "execute", agentTag, success, fireBody, invokeErr) if !softReturned {
w.invokeNotify.Fire(toolCallID, "execute", agentTag, success, fireBody, invokeErr)
}
} }
outW.Close() }(sr, userCmd, execCancel, timeoutCancel, execCtx, convID, execReg, toolRunReg, monitorExecID, tid, w.shellNoOutputTimeoutSec, w.toolWaitTimeoutSeconds)
}(sr, userCmd, execCancel, timeoutCancel, execCtx, convID, execReg, toolRunReg, monitorExecID, tid, w.shellNoOutputTimeoutSec)
return outR, nil return outR, nil
} }
func einoExecuteSoftWaitTimeoutResult(executionID string, waitTimeoutSec int) string {
waitText := "configured wait timeout"
if waitTimeoutSec > 0 {
waitText = fmt.Sprintf("%ds", waitTimeoutSec)
}
return fmt.Sprintf(`工具已提交到后台执行当前仍在运行
execution_id: %s
status: running
wait_timeout: %s
你可以继续推理改用其他工具或调用 get_tool_execution / wait_tool_execution 读取 partial_output 并继续等待也可以调用 cancel_tool_execution 取消`, executionID, waitText)
}
@@ -153,6 +153,81 @@ func TestEinoStreamingShellWrap_InactivityAfterPartialOutput(t *testing.T) {
} }
} }
func TestEinoStreamingShellWrap_SoftWaitTimeoutReturnsExecutionIDAndKeepsRunning(t *testing.T) {
inner := &mockStreamingShellPartialThenHang{}
partialCh := make(chan string, 4)
cancelCh := make(chan context.CancelFunc, 1)
unregistered := make(chan string, 1)
wrap := &einoStreamingShellWrap{
inner: inner,
toolWaitTimeoutSeconds: 1,
beginMonitor: func(toolCallID, command string) string {
return "exec-soft-wait"
},
appendPartialMonitor: func(executionID, toolCallID, chunk string) {
partialCh <- chunk
},
registerCancelMonitor: func(executionID string, cancel context.CancelFunc) {
if executionID == "exec-soft-wait" {
cancelCh <- cancel
}
},
unregisterCancelMonitor: func(executionID string) {
unregistered <- executionID
},
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sr, err := wrap.ExecuteStreaming(ctx, &filesystem.ExecuteRequest{Command: "sudo whoami"})
if err != nil {
t.Fatalf("ExecuteStreaming: %v", err)
}
defer sr.Close()
var got strings.Builder
for {
resp, rerr := sr.Recv()
if errors.Is(rerr, io.EOF) {
break
}
if rerr != nil {
t.Fatalf("recv: %v", rerr)
}
if resp != nil {
got.WriteString(resp.Output)
}
}
body := got.String()
if !strings.Contains(body, "execution_id: exec-soft-wait") || !strings.Contains(body, "status: running") {
t.Fatalf("expected background execution marker, got: %q", body)
}
select {
case chunk := <-partialCh:
if !strings.Contains(chunk, "[sudo] password") {
t.Fatalf("unexpected partial chunk: %q", chunk)
}
default:
t.Fatal("expected streamed partial output before soft wait return")
}
if !inner.called {
t.Fatal("inner shell did not run")
}
select {
case registeredCancel := <-cancelCh:
registeredCancel()
case <-time.After(time.Second):
t.Fatal("expected execution cancel registration")
}
select {
case id := <-unregistered:
if id != "exec-soft-wait" {
t.Fatalf("unexpected unregistered id: %q", id)
}
case <-time.After(time.Second):
t.Fatal("expected execution cancel unregister")
}
}
type mockStreamingShellHanging struct { type mockStreamingShellHanging struct {
called bool called bool
} }
+2 -2
View File
@@ -82,7 +82,7 @@ func RunEinoSingleChatModelAgent(
} }
toolInvokeNotify := einomcp.NewToolInvokeNotifyHolder() toolInvokeNotify := einomcp.NewToolInvokeNotifyHolder()
einoExecBegin, einoExecFinish := newEinoExecuteMonitorCallbacks(ctx, ag, recorder) einoExecBegin, einoExecAppendPartial, einoExecRegisterCancel, einoExecUnregisterCancel, einoExecFinish := newEinoExecuteMonitorCallbacks(ctx, ag, recorder)
mainDefs := ag.ToolsForRole(roleTools) mainDefs := ag.ToolsForRole(roleTools)
mainTools, err := einomcp.ToolsFromDefinitions(ag, holder, mainDefs, recorder, nil, toolInvokeNotify, einoSingleAgentName) mainTools, err := einomcp.ToolsFromDefinitions(ag, holder, mainDefs, recorder, nil, toolInvokeNotify, einoSingleAgentName)
if err != nil { if err != nil {
@@ -139,7 +139,7 @@ func RunEinoSingleChatModelAgent(
} }
if einoSkillMW != nil { if einoSkillMW != nil {
if einoFSTools && einoLoc != nil { if einoFSTools && einoLoc != nil {
fsMw, fsErr := subAgentFilesystemMiddleware(ctx, einoLoc, toolInvokeNotify, einoSingleAgentName, einoExecBegin, einoExecFinish, agentToolTimeoutMinutes(appCfg), agentShellNoOutputTimeoutSeconds(appCfg), nil) fsMw, fsErr := subAgentFilesystemMiddleware(ctx, einoLoc, toolInvokeNotify, einoSingleAgentName, einoExecBegin, einoExecAppendPartial, einoExecRegisterCancel, einoExecUnregisterCancel, einoExecFinish, agentToolTimeoutMinutes(appCfg), agentToolWaitTimeoutSeconds(appCfg), agentShellNoOutputTimeoutSeconds(appCfg), nil)
if fsErr != nil { if fsErr != nil {
return nil, fmt.Errorf("eino single filesystem 中间件: %w", fsErr) return nil, fmt.Errorf("eino single filesystem 中间件: %w", fsErr)
} }
+15
View File
@@ -107,8 +107,12 @@ func subAgentFilesystemMiddleware(
invokeNotify *einomcp.ToolInvokeNotifyHolder, invokeNotify *einomcp.ToolInvokeNotifyHolder,
einoAgentName string, einoAgentName string,
beginMonitor func(toolCallID, command string) string, beginMonitor func(toolCallID, command string) string,
appendPartialMonitor func(executionID, toolCallID, chunk string),
registerCancelMonitor func(executionID string, cancel context.CancelFunc),
unregisterCancelMonitor func(executionID string),
finishMonitor func(executionID, toolCallID, command, stdout string, success bool, invokeErr error), finishMonitor func(executionID, toolCallID, command, stdout string, success bool, invokeErr error),
toolTimeoutMinutes int, toolTimeoutMinutes int,
toolWaitTimeoutSeconds int,
shellNoOutputTimeoutSec int, shellNoOutputTimeoutSec int,
outputChunk func(toolName, toolCallID, chunk string), outputChunk func(toolName, toolCallID, chunk string),
) (adk.ChatModelAgentMiddleware, error) { ) (adk.ChatModelAgentMiddleware, error) {
@@ -123,8 +127,12 @@ func subAgentFilesystemMiddleware(
einoAgentName: strings.TrimSpace(einoAgentName), einoAgentName: strings.TrimSpace(einoAgentName),
outputChunk: outputChunk, outputChunk: outputChunk,
beginMonitor: beginMonitor, beginMonitor: beginMonitor,
appendPartialMonitor: appendPartialMonitor,
registerCancelMonitor: registerCancelMonitor,
unregisterCancelMonitor: unregisterCancelMonitor,
finishMonitor: finishMonitor, finishMonitor: finishMonitor,
toolTimeoutMinutes: toolTimeoutMinutes, toolTimeoutMinutes: toolTimeoutMinutes,
toolWaitTimeoutSeconds: toolWaitTimeoutSeconds,
shellNoOutputTimeoutSec: shellNoOutputTimeoutSec, shellNoOutputTimeoutSec: shellNoOutputTimeoutSec,
}, },
}) })
@@ -138,6 +146,13 @@ func agentToolTimeoutMinutes(cfg *config.Config) int {
return cfg.Agent.ToolTimeoutMinutes return cfg.Agent.ToolTimeoutMinutes
} }
func agentToolWaitTimeoutSeconds(cfg *config.Config) int {
if cfg == nil {
return 0
}
return cfg.Agent.ToolWaitTimeoutSeconds
}
// agentShellNoOutputTimeoutSeconds0=默认 300s5 分钟);-1=关闭;>0=自定义秒数。 // agentShellNoOutputTimeoutSeconds0=默认 300s5 分钟);-1=关闭;>0=自定义秒数。
func agentShellNoOutputTimeoutSeconds(cfg *config.Config) int { func agentShellNoOutputTimeoutSeconds(cfg *config.Config) int {
if cfg == nil { if cfg == nil {
+7 -3
View File
@@ -135,7 +135,7 @@ func RunDeepAgent(
mcpIDs = append(mcpIDs, id) mcpIDs = append(mcpIDs, id)
mcpIDsMu.Unlock() mcpIDsMu.Unlock()
} }
einoExecBegin, einoExecFinish := newEinoExecuteMonitorCallbacks(ctx, ag, recorder) einoExecBegin, einoExecAppendPartial, einoExecRegisterCancel, einoExecUnregisterCancel, einoExecFinish := newEinoExecuteMonitorCallbacks(ctx, ag, recorder)
// 与单代理流式一致:在 response_start / response_delta 的 data 中带当前 mcpExecutionIds,供主聊天绑定复制与展示。 // 与单代理流式一致:在 response_start / response_delta 的 data 中带当前 mcpExecutionIds,供主聊天绑定复制与展示。
snapshotMCPIDs := func() []string { snapshotMCPIDs := func() []string {
@@ -240,7 +240,7 @@ func RunDeepAgent(
} }
if einoSkillMW != nil { if einoSkillMW != nil {
if einoFSTools && einoLoc != nil { if einoFSTools && einoLoc != nil {
subFs, fsErr := subAgentFilesystemMiddleware(ctx, einoLoc, toolInvokeNotify, id, einoExecBegin, einoExecFinish, agentToolTimeoutMinutes(appCfg), agentShellNoOutputTimeoutSeconds(appCfg), nil) subFs, fsErr := subAgentFilesystemMiddleware(ctx, einoLoc, toolInvokeNotify, id, einoExecBegin, einoExecAppendPartial, einoExecRegisterCancel, einoExecUnregisterCancel, einoExecFinish, agentToolTimeoutMinutes(appCfg), agentToolWaitTimeoutSeconds(appCfg), agentShellNoOutputTimeoutSeconds(appCfg), nil)
if fsErr != nil { if fsErr != nil {
return nil, fmt.Errorf("子代理 %q filesystem 中间件: %w", id, fsErr) return nil, fmt.Errorf("子代理 %q filesystem 中间件: %w", id, fsErr)
} }
@@ -387,8 +387,12 @@ func RunDeepAgent(
einoAgentName: orchestratorName, einoAgentName: orchestratorName,
outputChunk: nil, outputChunk: nil,
beginMonitor: einoExecBegin, beginMonitor: einoExecBegin,
appendPartialMonitor: einoExecAppendPartial,
registerCancelMonitor: einoExecRegisterCancel,
unregisterCancelMonitor: einoExecUnregisterCancel,
finishMonitor: einoExecFinish, finishMonitor: einoExecFinish,
toolTimeoutMinutes: agentToolTimeoutMinutes(appCfg), toolTimeoutMinutes: agentToolTimeoutMinutes(appCfg),
toolWaitTimeoutSeconds: agentToolWaitTimeoutSeconds(appCfg),
shellNoOutputTimeoutSec: agentShellNoOutputTimeoutSeconds(appCfg), shellNoOutputTimeoutSec: agentShellNoOutputTimeoutSeconds(appCfg),
} }
} }
@@ -486,7 +490,7 @@ func RunDeepAgent(
// 构建 filesystem 中间件(与 Deep sub-agent 一致) // 构建 filesystem 中间件(与 Deep sub-agent 一致)
var peFsMw adk.ChatModelAgentMiddleware var peFsMw adk.ChatModelAgentMiddleware
if einoSkillMW != nil && einoFSTools && einoLoc != nil { if einoSkillMW != nil && einoFSTools && einoLoc != nil {
peFsMw, err = subAgentFilesystemMiddleware(ctx, einoLoc, toolInvokeNotify, "executor", einoExecBegin, einoExecFinish, agentToolTimeoutMinutes(appCfg), agentShellNoOutputTimeoutSeconds(appCfg), nil) peFsMw, err = subAgentFilesystemMiddleware(ctx, einoLoc, toolInvokeNotify, "executor", einoExecBegin, einoExecAppendPartial, einoExecRegisterCancel, einoExecUnregisterCancel, einoExecFinish, agentToolTimeoutMinutes(appCfg), agentToolWaitTimeoutSeconds(appCfg), agentShellNoOutputTimeoutSeconds(appCfg), nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("plan_execute filesystem 中间件: %w", err) return nil, fmt.Errorf("plan_execute filesystem 中间件: %w", err)
} }
+21 -4
View File
@@ -74,6 +74,21 @@ func (e *Executor) SetToolOutputSpillRoot(rootDir string) {
e.spillRootDir = strings.TrimSpace(rootDir) e.spillRootDir = strings.TrimSpace(rootDir)
} }
func (e *Executor) wrapToolOutputCallback(ctx context.Context, cb ToolOutputCallback) ToolOutputCallback {
executionID := mcp.MCPExecutionIDFromContext(ctx)
if e == nil || e.mcpServer == nil || strings.TrimSpace(executionID) == "" {
return cb
}
return func(chunk string) {
if chunk != "" {
e.mcpServer.AppendToolExecutionPartialOutput(executionID, chunk)
}
if cb != nil {
cb(chunk)
}
}
}
func (e *Executor) spillOptsFromContext(ctx context.Context) tooloutput.SpillOpts { func (e *Executor) spillOptsFromContext(ctx context.Context) tooloutput.SpillOpts {
root := "" root := ""
if e != nil { if e != nil {
@@ -184,8 +199,9 @@ func (e *Executor) ExecuteTool(ctx context.Context, toolName string, args map[st
var output string var output string
var err error var err error
spill := e.spillOptsFromContext(ctx) spill := e.spillOptsFromContext(ctx)
// 如果上层提供了 stdout/stderr 增量回调,则边执行边读取并回调。 // 如果上层提供了 stdout/stderr 增量回调,或当前处于 MCP execution 中,则边执行边读取并回调。
if cb, ok := ctx.Value(ToolOutputCallbackCtxKey).(ToolOutputCallback); ok && cb != nil { if cb, ok := ctx.Value(ToolOutputCallbackCtxKey).(ToolOutputCallback); (ok && cb != nil) || mcp.MCPExecutionIDFromContext(ctx) != "" {
cb = e.wrapToolOutputCallback(ctx, cb)
output, err = streamCommandOutput(ctx, cmd, cb, ResolveShellNoOutputTimeoutSeconds(e.shellNoOutputTimeoutSec), e.toolOutputMaxBytes, spill) output, err = streamCommandOutput(ctx, cmd, cb, ResolveShellNoOutputTimeoutSeconds(e.shellNoOutputTimeoutSec), e.toolOutputMaxBytes, spill)
if err != nil && shouldRetryWithPTY(output) { if err != nil && shouldRetryWithPTY(output) {
e.logger.Info("检测到工具需要 TTY,使用 PTY 重试", e.logger.Info("检测到工具需要 TTY,使用 PTY 重试",
@@ -948,8 +964,9 @@ func (e *Executor) executeSystemCommand(ctx context.Context, args map[string]int
var output string var output string
var err error var err error
spill := e.spillOptsFromContext(ctx) spill := e.spillOptsFromContext(ctx)
// 若上层提供工具输出增量回调,则边执行边流式读取。 // 若上层提供工具输出增量回调,或当前处于 MCP execution 中,则边执行边流式读取。
if cb, ok := ctx.Value(ToolOutputCallbackCtxKey).(ToolOutputCallback); ok && cb != nil { if cb, ok := ctx.Value(ToolOutputCallbackCtxKey).(ToolOutputCallback); (ok && cb != nil) || mcp.MCPExecutionIDFromContext(ctx) != "" {
cb = e.wrapToolOutputCallback(ctx, cb)
output, err = streamCommandOutput(ctx, cmd, cb, ResolveShellNoOutputTimeoutSeconds(e.shellNoOutputTimeoutSec), e.toolOutputMaxBytes, spill) output, err = streamCommandOutput(ctx, cmd, cb, ResolveShellNoOutputTimeoutSeconds(e.shellNoOutputTimeoutSec), e.toolOutputMaxBytes, spill)
if err != nil && shouldRetryWithPTY(output) { if err != nil && shouldRetryWithPTY(output) {
e.logger.Info("检测到系统命令需要 TTY,使用 PTY 重试") e.logger.Info("检测到系统命令需要 TTY,使用 PTY 重试")
+37
View File
@@ -73,6 +73,43 @@ func TestExecuteSystemCommand_BackgroundDoesNotBlockOnChildStdout(t *testing.T)
} }
} }
func TestExecToolSoftWaitExposesPartialOutput(t *testing.T) {
executor, server := setupTestExecutor(t)
server.ConfigureToolWaitTimeoutSeconds(1)
mcp.RegisterExecutionControlTools(server, nil)
server.RegisterTool(mcp.Tool{Name: "exec", InputSchema: map[string]interface{}{"type": "object"}}, func(ctx context.Context, args map[string]interface{}) (*mcp.ToolResult, error) {
return executor.ExecuteTool(ctx, "exec", args)
})
result, executionID, err := server.CallTool(context.Background(), "exec", map[string]interface{}{
"command": "for i in 1 2 3 4; do echo partial-$i; sleep 0.3; done; sleep 5",
"shell": "sh",
})
if err != nil {
t.Fatalf("CallTool exec: %v", err)
}
if executionID == "" || result == nil || !result.IsError {
t.Fatalf("expected soft wait timeout, id=%q result=%#v", executionID, result)
}
status, _, err := server.CallTool(context.Background(), "get_tool_execution", map[string]interface{}{
"execution_id": executionID,
"include_partial_output": true,
"partial_output_max_bytes": 4096,
})
if err != nil {
t.Fatalf("get_tool_execution: %v", err)
}
body := mcp.ToolResultPlainText(status)
if !strings.Contains(body, `"status": "running"`) {
t.Fatalf("expected running execution, got: %s", body)
}
if !strings.Contains(body, "partial-") || !strings.Contains(body, "partial_output") {
t.Fatalf("expected partial output in execution status, got: %s", body)
}
server.CancelToolExecution(executionID)
}
func TestExecuteSystemCommand_FailureFormat(t *testing.T) { func TestExecuteSystemCommand_FailureFormat(t *testing.T) {
executor, _ := setupTestExecutor(t) executor, _ := setupTestExecutor(t)
res, err := executor.executeSystemCommand(context.Background(), map[string]interface{}{ res, err := executor.executeSystemCommand(context.Background(), map[string]interface{}{
+37 -1
View File
@@ -5468,7 +5468,7 @@ html[data-theme="dark"] .login-card .login-submit:disabled {
#mcp-detail-modal .detail-code-card { #mcp-detail-modal .detail-code-card {
padding: 0; padding: 0;
overflow: hidden; overflow: auto;
background: #f8fafc; background: #f8fafc;
border: 1px solid rgba(148, 163, 184, 0.2); border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 8px; border-radius: 8px;
@@ -5530,6 +5530,7 @@ html[data-theme="dark"] .login-card .login-submit:disabled {
font-size: 0.84rem; font-size: 0.84rem;
line-height: 1.7; line-height: 1.7;
white-space: pre-wrap; white-space: pre-wrap;
overflow: auto;
overflow-wrap: anywhere; overflow-wrap: anywhere;
word-break: break-word; word-break: break-word;
} }
@@ -10766,6 +10767,11 @@ html[data-theme="dark"] .robot-binding-service-hint-icon {
background: rgba(239, 68, 68, 0.1); background: rgba(239, 68, 68, 0.1);
} }
.mcp-stats-kpi__chip.is-neutral {
color: #475569;
background: rgba(100, 116, 139, 0.12);
}
.mcp-stats-kpi__status { .mcp-stats-kpi__status {
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 500; font-weight: 500;
@@ -29950,6 +29956,20 @@ html[data-theme="dark"] #info-collect-syntax-panel.is-generated-target {
margin-bottom: 16px; margin-bottom: 16px;
} }
.chat-files-source-badge {
display: inline-flex;
align-items: center;
margin-left: 8px;
padding: 2px 6px;
border: 1px solid rgba(99, 102, 241, 0.25);
border-radius: 6px;
background: rgba(99, 102, 241, 0.08);
color: #4f46e5;
font-size: 0.72rem;
line-height: 1;
vertical-align: middle;
}
.chat-files-filter-native-select { .chat-files-filter-native-select {
position: absolute; position: absolute;
width: 1px; width: 1px;
@@ -30098,6 +30118,16 @@ html[data-theme="dark"] #info-collect-syntax-panel.is-generated-target {
border-radius: 8px; border-radius: 8px;
} }
.chat-files-pagination {
margin-top: 12px;
border: 1px solid var(--border-color);
border-radius: 8px;
}
.chat-files-pagination[hidden] {
display: none;
}
.chat-files-table-wrap.drag-over { .chat-files-table-wrap.drag-over {
background: rgba(0, 102, 255, 0.06); background: rgba(0, 102, 255, 0.06);
outline: 2px dashed rgba(0, 102, 255, 0.35); outline: 2px dashed rgba(0, 102, 255, 0.35);
@@ -34263,6 +34293,12 @@ html[data-theme="dark"] .chat-files-cell-conv code {
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
} }
html[data-theme="dark"] .chat-files-source-badge {
border-color: rgba(129, 140, 248, 0.35);
background: rgba(129, 140, 248, 0.12);
color: #c7d2fe;
}
html[data-theme="dark"] code, html[data-theme="dark"] code,
html[data-theme="dark"] pre, html[data-theme="dark"] pre,
html[data-theme="dark"] .code-block, html[data-theme="dark"] .code-block,
+26 -2
View File
@@ -210,6 +210,7 @@
"toolsCountLabel_other": "{{count}} tools", "toolsCountLabel_other": "{{count}} tools",
"failedNCalls_one": "{{count}} failed", "failedNCalls_one": "{{count}} failed",
"failedNCalls_other": "{{count}} failed", "failedNCalls_other": "{{count}} failed",
"noCompletedYet": "No completed outcomes yet",
"noCallYet": "No calls yet", "noCallYet": "No calls yet",
"allClear": "No new risks", "allClear": "No new risks",
"allIdle": "System idle", "allIdle": "System idle",
@@ -2014,6 +2015,7 @@
"webshellExec": "Execute WebShell command", "webshellExec": "Execute WebShell command",
"webshellFileOp": "WebShell file operation", "webshellFileOp": "WebShell file operation",
"listChatUploads": "List uploads", "listChatUploads": "List uploads",
"exportChatUploads": "Export uploads",
"uploadChatFile": "Upload file", "uploadChatFile": "Upload file",
"deleteChatUpload": "Delete upload", "deleteChatUpload": "Delete upload",
"downloadChatUpload": "Download upload", "downloadChatUpload": "Download upload",
@@ -2170,6 +2172,7 @@
"lastCall": "Last call", "lastCall": "Last call",
"lastRefreshTime": "Last refresh", "lastRefreshTime": "Last refresh",
"noCallsYet": "No calls yet", "noCallsYet": "No calls yet",
"noCompletedYet": "No completed outcomes yet",
"unknownTool": "Unknown tool", "unknownTool": "Unknown tool",
"successFailedRate": "Success {{success}} / Failed {{failed}} · {{rate}}% success rate", "successFailedRate": "Success {{success}} / Failed {{failed}} · {{rate}}% success rate",
"topToolsTitle": "Top {{n}} tools by calls", "topToolsTitle": "Top {{n}} tools by calls",
@@ -2192,6 +2195,7 @@
"clearToolFilter": "Clear tool filter", "clearToolFilter": "Clear tool filter",
"successCount": "Success {{n}}", "successCount": "Success {{n}}",
"failedCount": "Failed {{n}}", "failedCount": "Failed {{n}}",
"neutralCount": "Stopped {{n}}",
"rateHealthy": "Running smoothly", "rateHealthy": "Running smoothly",
"rateWarning": "Some failures detected", "rateWarning": "Some failures detected",
"rateCritical": "High failure rate", "rateCritical": "High failure rate",
@@ -2345,20 +2349,40 @@
"chatFilesPage": { "chatFilesPage": {
"title": "File Management", "title": "File Management",
"intro": "Files uploaded in chat appear here. Drag files into the list below, or click Upload to pick files (multiple allowed). Click “Copy path” to copy the server absolute path and paste it into a conversation so the model can reference the file.", "intro": "Files uploaded in chat appear here. Drag files into the list below, or click Upload to pick files (multiple allowed). Click “Copy path” to copy the server absolute path and paste it into a conversation so the model can reference the file.",
"export": "Export Current Results",
"exportStarted": "Started exporting current files",
"exportEmpty": "No files to export",
"upload": "Upload", "upload": "Upload",
"conversationFilter": "Conversation ID", "conversationFilter": "Conversation ID",
"conversationPlaceholder": "Leave empty for all", "conversationPlaceholder": "Leave empty for all",
"projectFilter": "Project ID",
"projectPlaceholder": "Leave empty for all",
"searchName": "File name", "searchName": "File name",
"searchNamePlaceholder": "Filter by file name", "searchNamePlaceholder": "Filter by file name",
"sourceFilter": "Source",
"sourceAll": "All",
"sourceUpload": "Chat uploads",
"sourceReduction": "Tool outputs",
"sourceConversationArtifact": "Conversation artifacts",
"groupBy": "Group by", "groupBy": "Group by",
"groupNone": "None (flat list)", "groupNone": "None (flat list)",
"groupByDate": "By date", "groupByDate": "By date",
"groupByConversation": "By conversation", "groupByConversation": "By conversation",
"groupByProject": "By project",
"groupByFolder": "By folder (path navigation)", "groupByFolder": "By folder (path navigation)",
"browseRoot": "chat_uploads", "projectUnbound": "Unbound project",
"paginationInfo": "Showing {{start}}-{{end}} of {{total}} files",
"paginationPage": "Page {{page}} of {{totalPages}}",
"pageSize": "Per page",
"prevPage": "Previous",
"nextPage": "Next",
"browseRoot": "Files",
"treeUploadsRoot": "Chat uploads",
"treeReductionRoot": "Tool outputs",
"treeArtifactsRoot": "Conversation artifacts",
"browseUp": "Up", "browseUp": "Up",
"enterFolderTitle": "Open folder", "enterFolderTitle": "Open folder",
"copyFolderPathTitle": "Copy relative path under chat_uploads/…", "copyFolderPathTitle": "Copy folder path",
"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.",
+26 -2
View File
@@ -207,6 +207,7 @@
"highCountLabel": "高危 {{count}}", "highCountLabel": "高危 {{count}}",
"toolsCountLabel": "{{count}} 个工具", "toolsCountLabel": "{{count}} 个工具",
"failedNCalls": "{{count}} 次失败", "failedNCalls": "{{count}} 次失败",
"noCompletedYet": "暂无有效完成",
"noCallYet": "暂无调用", "noCallYet": "暂无调用",
"allClear": "暂无新增风险", "allClear": "暂无新增风险",
"allIdle": "系统空闲", "allIdle": "系统空闲",
@@ -2002,6 +2003,7 @@
"webshellExec": "执行WebShell命令", "webshellExec": "执行WebShell命令",
"webshellFileOp": "WebShell文件操作", "webshellFileOp": "WebShell文件操作",
"listChatUploads": "列出附件", "listChatUploads": "列出附件",
"exportChatUploads": "导出附件",
"uploadChatFile": "上传附件", "uploadChatFile": "上传附件",
"deleteChatUpload": "删除附件", "deleteChatUpload": "删除附件",
"downloadChatUpload": "下载附件", "downloadChatUpload": "下载附件",
@@ -2158,6 +2160,7 @@
"lastCall": "最近一次调用", "lastCall": "最近一次调用",
"lastRefreshTime": "最后刷新时间", "lastRefreshTime": "最后刷新时间",
"noCallsYet": "暂无调用", "noCallsYet": "暂无调用",
"noCompletedYet": "暂无有效完成",
"unknownTool": "未知工具", "unknownTool": "未知工具",
"successFailedRate": "成功 {{success}} / 失败 {{failed}} · 成功率 {{rate}}%", "successFailedRate": "成功 {{success}} / 失败 {{failed}} · 成功率 {{rate}}%",
"topToolsTitle": "工具调用 Top {{n}}", "topToolsTitle": "工具调用 Top {{n}}",
@@ -2180,6 +2183,7 @@
"clearToolFilter": "清除工具筛选", "clearToolFilter": "清除工具筛选",
"successCount": "成功 {{n}}", "successCount": "成功 {{n}}",
"failedCount": "失败 {{n}}", "failedCount": "失败 {{n}}",
"neutralCount": "终止 {{n}}",
"rateHealthy": "运行平稳", "rateHealthy": "运行平稳",
"rateWarning": "存在失败调用", "rateWarning": "存在失败调用",
"rateCritical": "失败率偏高", "rateCritical": "失败率偏高",
@@ -2333,20 +2337,40 @@
"chatFilesPage": { "chatFilesPage": {
"title": "文件管理", "title": "文件管理",
"intro": "管理在对话中上传的文件。可将文件拖拽到下方列表区域,或点击「上传文件」选择文件(支持多选)。需要让 AI 引用某文件时,在列表中点击「复制路径」,到对话里粘贴即可(路径为服务器上的绝对路径,与对话附件保存位置一致)。", "intro": "管理在对话中上传的文件。可将文件拖拽到下方列表区域,或点击「上传文件」选择文件(支持多选)。需要让 AI 引用某文件时,在列表中点击「复制路径」,到对话里粘贴即可(路径为服务器上的绝对路径,与对话附件保存位置一致)。",
"export": "导出当前结果",
"exportStarted": "已开始导出当前文件",
"exportEmpty": "没有可导出的文件",
"upload": "上传文件", "upload": "上传文件",
"conversationFilter": "会话 ID", "conversationFilter": "会话 ID",
"conversationPlaceholder": "留空表示全部", "conversationPlaceholder": "留空表示全部",
"projectFilter": "项目 ID",
"projectPlaceholder": "留空表示全部",
"searchName": "文件名", "searchName": "文件名",
"searchNamePlaceholder": "筛选文件名", "searchNamePlaceholder": "筛选文件名",
"sourceFilter": "来源",
"sourceAll": "全部",
"sourceUpload": "对话附件",
"sourceReduction": "工具输出",
"sourceConversationArtifact": "会话产物",
"groupBy": "分组方式", "groupBy": "分组方式",
"groupNone": "不分组(平铺)", "groupNone": "不分组(平铺)",
"groupByDate": "按日期", "groupByDate": "按日期",
"groupByConversation": "按会话", "groupByConversation": "按会话",
"groupByProject": "按项目",
"groupByFolder": "按文件夹(路径浏览)", "groupByFolder": "按文件夹(路径浏览)",
"browseRoot": "chat_uploads", "projectUnbound": "未绑定项目",
"paginationInfo": "显示 {{start}}-{{end}} / {{total}} 个文件",
"paginationPage": "第 {{page}} / {{totalPages}} 页",
"pageSize": "每页",
"prevPage": "上一页",
"nextPage": "下一页",
"browseRoot": "文件",
"treeUploadsRoot": "对话附件",
"treeReductionRoot": "工具输出",
"treeArtifactsRoot": "会话产物",
"browseUp": "上级", "browseUp": "上级",
"enterFolderTitle": "进入此文件夹", "enterFolderTitle": "进入此文件夹",
"copyFolderPathTitle": "复制目录的相对路径(chat_uploads/…)", "copyFolderPathTitle": "复制目录路径",
"folderPathCopied": "目录路径已复制,可粘贴到对话中", "folderPathCopied": "目录路径已复制,可粘贴到对话中",
"folderEmpty": "此文件夹为空", "folderEmpty": "此文件夹为空",
"confirmDeleteFolder": "确定删除该文件夹及其中的全部文件?此操作不可恢复。", "confirmDeleteFolder": "确定删除该文件夹及其中的全部文件?此操作不可恢复。",
+387 -54
View File
@@ -6,18 +6,26 @@ let chatFilesFoldersCache = [];
let chatFilesDisplayed = []; let chatFilesDisplayed = [];
let chatFilesEditRelativePath = ''; let chatFilesEditRelativePath = '';
let chatFilesRenameRelativePath = ''; let chatFilesRenameRelativePath = '';
let chatFilesTotal = 0;
let chatFilesPage = 1;
let chatFilesPageSize = 20;
let chatFilesSearchDebounceTimer = null;
const CHAT_FILES_GROUP_STORAGE_KEY = 'csai_chat_files_group_by'; const CHAT_FILES_GROUP_STORAGE_KEY = 'csai_chat_files_group_by';
const CHAT_FILES_BROWSE_PATH_KEY = 'csai_chat_files_browse_path'; const CHAT_FILES_BROWSE_PATH_KEY = 'csai_chat_files_browse_path';
const CHAT_FILES_PAGE_SIZE_STORAGE_KEY = 'csai_chat_files_page_size';
const CHAT_FILES_TREE_UPLOAD_ROOT = 'uploads';
const CHAT_FILES_TREE_REDUCTION_ROOT = 'tool_outputs';
const CHAT_FILES_TREE_ARTIFACT_ROOT = 'conversation_artifacts';
/** 按文件夹浏览模式下的当前路径(相对 chat_uploads 的段数组),如 ['2024-03-21','uuid'] */ /** 按文件夹浏览模式下的当前路径(虚拟根段数组),如 ['uploads','2024-03-21','uuid'] */
let chatFilesBrowsePath = []; let chatFilesBrowsePath = [];
/** 非空时,下一次上传文件落到此相对路径(chat_uploads 下目录),如 2026-03-21/uuid/sub */ /** 非空时,下一次上传文件落到此相对路径(chat_uploads 下目录),如 2026-03-21/uuid/sub */
let chatFilesPendingUploadDir = ''; let chatFilesPendingUploadDir = '';
/** 文件管理页面向服务器上传进行中,避免重复选择并禁用顶栏按钮 */ /** 文件管理页面向服务器上传进行中,避免重复选择并禁用顶栏按钮 */
let chatFilesXHRUploadBusy = false; let chatFilesXHRUploadBusy = false;
const CHAT_FILES_FILTER_SELECT_IDS = ['chat-files-group-by']; const CHAT_FILES_FILTER_SELECT_IDS = ['chat-files-filter-source', 'chat-files-group-by'];
const chatFilesFilterSelectMap = {}; const chatFilesFilterSelectMap = {};
let chatFilesFilterSelectDocBound = false; let chatFilesFilterSelectDocBound = false;
const CHAT_FILES_FILTER_SELECT_CARET = '<svg class="chat-files-filter-select-caret" width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>'; const CHAT_FILES_FILTER_SELECT_CARET = '<svg class="chat-files-filter-select-caret" width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true"><path d="M6 9l6 6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
@@ -194,6 +202,17 @@ function chatFilesSetBrowsePath(path) {
} }
} }
function chatFilesLoadPageSizeFromStorage() {
try {
const n = parseInt(localStorage.getItem(CHAT_FILES_PAGE_SIZE_STORAGE_KEY) || '', 10);
if ([10, 20, 50, 100].includes(n)) {
chatFilesPageSize = n;
}
} catch (e) {
/* ignore */
}
}
function chatFilesResolveTreeNode(root, path) { function chatFilesResolveTreeNode(root, path) {
let node = root; let node = root;
let i; let i;
@@ -217,6 +236,7 @@ function chatFilesNormalizeBrowsePathForTree(root) {
function initChatFilesPage() { function initChatFilesPage() {
chatFilesLoadBrowsePathFromStorage(); chatFilesLoadBrowsePathFromStorage();
chatFilesLoadPageSizeFromStorage();
try { try {
localStorage.removeItem('csai_chat_files_synthetic_dirs'); localStorage.removeItem('csai_chat_files_synthetic_dirs');
} catch (e) { } catch (e) {
@@ -227,7 +247,7 @@ function initChatFilesPage() {
if (sel) { if (sel) {
try { try {
const v = localStorage.getItem(CHAT_FILES_GROUP_STORAGE_KEY); const v = localStorage.getItem(CHAT_FILES_GROUP_STORAGE_KEY);
if (v === 'none' || v === 'date' || v === 'conversation' || v === 'folder') { if (v === 'none' || v === 'date' || v === 'conversation' || v === 'project' || v === 'folder') {
sel.value = v; sel.value = v;
} }
} catch (e) { } catch (e) {
@@ -324,6 +344,8 @@ function ensureChatFilesDocClickClose() {
async function loadChatFilesPage() { async function loadChatFilesPage() {
const wrap = document.getElementById('chat-files-list-wrap'); const wrap = document.getElementById('chat-files-list-wrap');
if (!wrap) return; if (!wrap) return;
const pager = document.getElementById('chat-files-pagination');
if (pager) pager.hidden = true;
wrap.classList.remove('chat-files-table-wrap--grouped'); wrap.classList.remove('chat-files-table-wrap--grouped');
wrap.classList.remove('chat-files-table-wrap--tree'); wrap.classList.remove('chat-files-table-wrap--tree');
wrap.innerHTML = '<div class="loading-spinner" data-i18n="common.loading">加载中…</div>'; wrap.innerHTML = '<div class="loading-spinner" data-i18n="common.loading">加载中…</div>';
@@ -333,10 +355,31 @@ async function loadChatFilesPage() {
const conv = document.getElementById('chat-files-filter-conv'); const conv = document.getElementById('chat-files-filter-conv');
const convQ = conv ? conv.value.trim() : ''; const convQ = conv ? conv.value.trim() : '';
let url = '/api/chat-uploads'; const project = document.getElementById('chat-files-filter-project');
const projectQ = project ? project.value.trim() : '';
const source = document.getElementById('chat-files-filter-source');
const sourceQ = source ? source.value : 'all';
const search = document.getElementById('chat-files-filter-name');
const searchQ = search ? search.value.trim() : '';
const groupMode = chatFilesGetGroupByMode();
const params = new URLSearchParams();
if (convQ) { if (convQ) {
url += '?conversation=' + encodeURIComponent(convQ); params.set('conversation', convQ);
} }
if (projectQ) {
params.set('project', projectQ);
}
if (sourceQ && sourceQ !== 'all') {
params.set('source', sourceQ);
}
if (searchQ) {
params.set('search', searchQ);
}
params.set('page', String(chatFilesPage));
params.set('pageSize', groupMode === 'folder' ? 'all' : String(chatFilesPageSize));
let url = '/api/chat-uploads';
const query = params.toString();
if (query) url += '?' + query;
try { try {
const res = await apiFetch(url); const res = await apiFetch(url);
@@ -347,6 +390,16 @@ 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 : []; chatFilesFoldersCache = Array.isArray(data.folders) ? data.folders : [];
chatFilesTotal = Number.isFinite(Number(data.total)) ? Number(data.total) : chatFilesCache.length;
chatFilesPage = Number.isFinite(Number(data.page)) ? Math.max(1, Number(data.page)) : chatFilesPage;
if (Number.isFinite(Number(data.pageSize)) && Number(data.pageSize) > 0) {
chatFilesPageSize = Number(data.pageSize);
}
if (groupMode !== 'folder' && chatFilesTotal > 0 && chatFilesCache.length === 0 && chatFilesPage > chatFilesTotalPages()) {
chatFilesPage = chatFilesTotalPages();
loadChatFilesPage();
return;
}
renderChatFilesTable(); renderChatFilesTable();
} catch (e) { } catch (e) {
console.error(e); console.error(e);
@@ -354,24 +407,141 @@ async function loadChatFilesPage() {
wrap.classList.remove('chat-files-table-wrap--tree'); wrap.classList.remove('chat-files-table-wrap--tree');
const msg = (typeof window.t === 'function') ? window.t('chatFilesPage.errorLoad') : '加载失败'; const msg = (typeof window.t === 'function') ? window.t('chatFilesPage.errorLoad') : '加载失败';
wrap.innerHTML = '<div class="error-message">' + escapeHtml(msg + ': ' + (e.message || String(e))) + '</div>'; wrap.innerHTML = '<div class="error-message">' + escapeHtml(msg + ': ' + (e.message || String(e))) + '</div>';
renderChatFilesPagination();
}
}
async function exportChatFiles() {
const conv = document.getElementById('chat-files-filter-conv');
const project = document.getElementById('chat-files-filter-project');
const convQ = conv ? conv.value.trim() : '';
const projectQ = project ? project.value.trim() : '';
const source = document.getElementById('chat-files-filter-source');
const sourceQ = source ? source.value : 'all';
const search = document.getElementById('chat-files-filter-name');
const searchQ = search ? search.value.trim() : '';
const params = new URLSearchParams();
if (convQ) params.set('conversation', convQ);
if (projectQ) params.set('project', projectQ);
if (sourceQ && sourceQ !== 'all') params.set('source', sourceQ);
if (searchQ) params.set('search', searchQ);
const url = '/api/chat-uploads/export' + (params.toString() ? ('?' + params.toString()) : '');
try {
const res = await apiFetch(url);
if (!res.ok) {
const raw = await res.text();
let msg = raw;
try {
const j = JSON.parse(raw);
if (j && j.error) msg = j.error;
} catch (e) {
/* keep raw */
}
if (res.status === 404) {
msg = (typeof window.t === 'function') ? window.t('chatFilesPage.exportEmpty') : '没有可导出的文件';
}
throw new Error(msg || String(res.status));
}
const blob = await res.blob();
const disposition = res.headers.get('Content-Disposition') || '';
let filename = 'chat-files-export.zip';
const m = disposition.match(/filename="?([^"]+)"?/i);
if (m && m[1]) filename = m[1];
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
a.click();
URL.revokeObjectURL(a.href);
const ok = (typeof window.t === 'function') ? window.t('chatFilesPage.exportStarted') : '已开始导出';
chatFilesShowToast(ok);
} catch (e) {
alert((e && e.message) ? e.message : String(e));
} }
} }
function chatFilesNameFilter(files) { function chatFilesNameFilter(files) {
const el = document.getElementById('chat-files-filter-name'); return Array.isArray(files) ? files : [];
const q = el ? el.value.trim().toLowerCase() : ''; }
if (!q) return files;
return files.filter(function (f) { function chatFilesResetToFirstPageAndLoad() {
const name = (f.name || '').toLowerCase(); chatFilesPage = 1;
const sub = (f.subPath || '').toLowerCase(); loadChatFilesPage();
return name.includes(q) || sub.includes(q);
});
} }
/** 仅前端按文件名筛选,不重新请求 */
function chatFilesFilterNameOnInput() { function chatFilesFilterNameOnInput() {
if (!chatFilesCache.length && !chatFilesFoldersCache.length && chatFilesGetGroupByMode() !== 'folder') return; if (chatFilesSearchDebounceTimer) clearTimeout(chatFilesSearchDebounceTimer);
renderChatFilesTable(); chatFilesSearchDebounceTimer = setTimeout(function () {
chatFilesSearchDebounceTimer = null;
chatFilesResetToFirstPageAndLoad();
}, 250);
}
function chatFilesTotalPages() {
if (chatFilesGetGroupByMode() === 'folder') return 1;
return Math.max(1, Math.ceil((chatFilesTotal || 0) / Math.max(1, chatFilesPageSize)));
}
function renderChatFilesPagination() {
const pager = document.getElementById('chat-files-pagination');
if (!pager) return;
const groupMode = chatFilesGetGroupByMode();
if (groupMode === 'folder' || chatFilesTotal <= 0) {
pager.hidden = true;
pager.innerHTML = '';
return;
}
const totalPages = chatFilesTotalPages();
if (chatFilesPage > totalPages) {
chatFilesPage = totalPages;
}
const start = (chatFilesPage - 1) * chatFilesPageSize + 1;
const end = Math.min(chatFilesTotal, chatFilesPage * chatFilesPageSize);
const info = (typeof window.t === 'function')
? window.t('chatFilesPage.paginationInfo', { start: start, end: end, total: chatFilesTotal })
: ('显示 ' + start + '-' + end + ' / ' + chatFilesTotal);
const pageText = (typeof window.t === 'function')
? window.t('chatFilesPage.paginationPage', { page: chatFilesPage, totalPages: totalPages })
: ('第 ' + chatFilesPage + ' / ' + totalPages + ' 页');
const pageSizeLabel = (typeof window.t === 'function') ? window.t('chatFilesPage.pageSize') : '每页';
const prevLabel = (typeof window.t === 'function') ? window.t('chatFilesPage.prevPage') : '上一页';
const nextLabel = (typeof window.t === 'function') ? window.t('chatFilesPage.nextPage') : '下一页';
const sizes = [10, 20, 50, 100].map(function (n) {
return '<option value="' + n + '"' + (n === chatFilesPageSize ? ' selected' : '') + '>' + n + '</option>';
}).join('');
pager.innerHTML = `
<div class="pagination-info">
<span>${escapeHtml(info)}</span>
<label class="pagination-page-size">${escapeHtml(pageSizeLabel)}
<select onchange="changeChatFilesPageSize(this.value)">${sizes}</select>
</label>
</div>
<div class="pagination-controls">
<button type="button" class="btn-secondary" ${chatFilesPage <= 1 ? 'disabled' : ''} onclick="changeChatFilesPage(${chatFilesPage - 1})">${escapeHtml(prevLabel)}</button>
<span class="pagination-page">${escapeHtml(pageText)}</span>
<button type="button" class="btn-secondary" ${chatFilesPage >= totalPages ? 'disabled' : ''} onclick="changeChatFilesPage(${chatFilesPage + 1})">${escapeHtml(nextLabel)}</button>
</div>`;
pager.hidden = false;
}
function changeChatFilesPage(page) {
const totalPages = chatFilesTotalPages();
const next = Math.min(totalPages, Math.max(1, parseInt(page, 10) || 1));
if (next === chatFilesPage) return;
chatFilesPage = next;
loadChatFilesPage();
}
function changeChatFilesPageSize(value) {
const n = parseInt(value, 10);
if (![10, 20, 50, 100].includes(n)) return;
chatFilesPageSize = n;
chatFilesPage = 1;
try {
localStorage.setItem(CHAT_FILES_PAGE_SIZE_STORAGE_KEY, String(n));
} catch (e) {
/* ignore */
}
loadChatFilesPage();
} }
function formatChatFileBytes(n) { function formatChatFileBytes(n) {
@@ -477,7 +647,7 @@ function chatFilesAlertMessage(raw) {
function chatFilesGetGroupByMode() { function chatFilesGetGroupByMode() {
const sel = document.getElementById('chat-files-group-by'); const sel = document.getElementById('chat-files-group-by');
const v = sel ? sel.value : 'none'; const v = sel ? sel.value : 'none';
if (v === 'date' || v === 'conversation' || v === 'folder') return v; if (v === 'date' || v === 'conversation' || v === 'project' || v === 'folder') return v;
return 'none'; return 'none';
} }
@@ -490,7 +660,7 @@ function chatFilesGroupByChange() {
/* ignore */ /* ignore */
} }
} }
renderChatFilesTable(); chatFilesResetToFirstPageAndLoad();
} }
function chatFilesCompareDateKeysDesc(a, b) { function chatFilesCompareDateKeysDesc(a, b) {
@@ -507,7 +677,7 @@ function chatFilesTreeMakeNode() {
} }
function chatFilesTreeInsertFile(root, f, idx) { function chatFilesTreeInsertFile(root, f, idx) {
const rp = String(f.relativePath || '').replace(/\\/g, '/').replace(/^\/+/, ''); const rp = chatFilesTreePathForFile(f);
if (!rp) return; if (!rp) return;
const parts = rp.split('/').filter(function (p) { const parts = rp.split('/').filter(function (p) {
return p.length > 0; return p.length > 0;
@@ -530,6 +700,21 @@ function chatFilesBuildTree(files) {
return root; return root;
} }
function chatFilesTreePathForFile(f) {
const source = f && f.source;
let rp = String((f && f.relativePath) || '').replace(/\\/g, '/').replace(/^\/+/, '');
if (!rp) return '';
if (source === 'reduction') {
rp = rp.replace(/^__reduction__\//, '');
return CHAT_FILES_TREE_REDUCTION_ROOT + '/' + rp;
}
if (source === 'conversation_artifact') {
rp = rp.replace(/^__conversation_artifact__\//, '');
return CHAT_FILES_TREE_ARTIFACT_ROOT + '/' + rp;
}
return CHAT_FILES_TREE_UPLOAD_ROOT + '/' + rp;
}
/** 将后端返回的目录相对路径(如 a/b/c)并入树,便于展示空文件夹 */ /** 将后端返回的目录相对路径(如 a/b/c)并入树,便于展示空文件夹 */
function chatFilesTreeInsertFolderPath(root, relSlash) { function chatFilesTreeInsertFolderPath(root, relSlash) {
const rp = String(relSlash || '').replace(/\\/g, '/').replace(/^\/+/, ''); const rp = String(relSlash || '').replace(/\\/g, '/').replace(/^\/+/, '');
@@ -549,18 +734,94 @@ function chatFilesTreeInsertFolderPath(root, relSlash) {
function chatFilesMergeFoldersIntoTree(root, folderPaths) { function chatFilesMergeFoldersIntoTree(root, folderPaths) {
if (!Array.isArray(folderPaths)) return; if (!Array.isArray(folderPaths)) return;
if (!chatFilesShouldMergeUploadFolders()) return;
let i; let i;
for (i = 0; i < folderPaths.length; i++) { for (i = 0; i < folderPaths.length; i++) {
chatFilesTreeInsertFolderPath(root, folderPaths[i]); chatFilesTreeInsertFolderPath(root, CHAT_FILES_TREE_UPLOAD_ROOT + '/' + folderPaths[i]);
} }
} }
function chatFilesTreeRootMerged() { function chatFilesTreeRootMerged() {
const root = chatFilesBuildTree(chatFilesDisplayed); const root = chatFilesBuildTree(chatFilesDisplayed);
chatFilesMergeFoldersIntoTree(root, chatFilesFoldersCache); chatFilesMergeFoldersIntoTree(root, chatFilesFoldersCache);
chatFilesEnsureSourceRoots(root);
return root; return root;
} }
function chatFilesCurrentSourceFilter() {
const el = document.getElementById('chat-files-filter-source');
return el ? (el.value || 'all') : 'all';
}
function chatFilesCurrentSearchQuery() {
const el = document.getElementById('chat-files-filter-name');
return el ? String(el.value || '').trim() : '';
}
function chatFilesShouldMergeUploadFolders() {
const source = chatFilesCurrentSourceFilter();
return (source === 'all' || source === 'upload') && !chatFilesCurrentSearchQuery();
}
function chatFilesEnsureSourceRoots(root) {
const source = chatFilesCurrentSourceFilter();
const roots = [];
if (source === 'all' || source === 'upload') roots.push(CHAT_FILES_TREE_UPLOAD_ROOT);
if (source === 'all' || source === 'reduction') roots.push(CHAT_FILES_TREE_REDUCTION_ROOT);
if (source === 'all' || source === 'conversation_artifact') roots.push(CHAT_FILES_TREE_ARTIFACT_ROOT);
roots.forEach(function (name) {
if (!root.dirs[name]) root.dirs[name] = chatFilesTreeMakeNode();
});
}
function chatFilesIsInternalSource(f) {
const source = f && f.source;
return source === 'reduction' || source === 'conversation_artifact';
}
function chatFilesSourceLabel(source) {
if (source === 'reduction') {
return (typeof window.t === 'function') ? window.t('chatFilesPage.sourceReduction') : '工具输出';
}
if (source === 'conversation_artifact') {
return (typeof window.t === 'function') ? window.t('chatFilesPage.sourceConversationArtifact') : '会话产物';
}
return (typeof window.t === 'function') ? window.t('chatFilesPage.sourceUpload') : '对话附件';
}
function chatFilesBrowseCanMutateCurrentPath() {
return chatFilesBrowsePath[0] === CHAT_FILES_TREE_UPLOAD_ROOT;
}
function chatFilesBrowseCanUploadToPath(path) {
return Array.isArray(path) && path[0] === CHAT_FILES_TREE_UPLOAD_ROOT;
}
function chatFilesBrowseCanDeleteFolderPath(path) {
return Array.isArray(path) && path[0] === CHAT_FILES_TREE_UPLOAD_ROOT && path.length > 1;
}
function chatFilesTreeDisplayName(name) {
if (name === CHAT_FILES_TREE_UPLOAD_ROOT) {
return (typeof window.t === 'function') ? window.t('chatFilesPage.treeUploadsRoot') : '对话附件';
}
if (name === CHAT_FILES_TREE_REDUCTION_ROOT) {
return (typeof window.t === 'function') ? window.t('chatFilesPage.treeReductionRoot') : '工具输出';
}
if (name === CHAT_FILES_TREE_ARTIFACT_ROOT) {
return (typeof window.t === 'function') ? window.t('chatFilesPage.treeArtifactsRoot') : '会话产物';
}
return name;
}
function chatFilesUploadRelativeDirFromBrowsePath(path) {
const parts = Array.isArray(path) ? path.slice() : [];
if (parts[0] === CHAT_FILES_TREE_UPLOAD_ROOT) {
parts.shift();
}
return parts.join('/');
}
function chatFilesTreeNodeMaxMod(node) { function chatFilesTreeNodeMaxMod(node) {
let m = 0; let m = 0;
let i; let i;
@@ -587,7 +848,14 @@ function chatFilesTreeSortDirKeys(node, keys) {
function chatFilesBuildGroups(files, mode) { function chatFilesBuildGroups(files, mode) {
const map = new Map(); const map = new Map();
files.forEach(function (f, idx) { files.forEach(function (f, idx) {
const key = mode === 'date' ? (f.date || '—') : (f.conversationId || '—'); let key;
if (mode === 'date') {
key = f.date || '—';
} else if (mode === 'project') {
key = f.projectId || '—';
} else {
key = f.conversationId || '—';
}
if (!map.has(key)) { if (!map.has(key)) {
map.set(key, { key: key, items: [] }); map.set(key, { key: key, items: [] });
} }
@@ -623,12 +891,20 @@ function chatFilesBuildGroups(files, mode) {
return groups; return groups;
} }
/** 分组标题:会话 ID 过长时缩短展示,完整值放在 title */ /** 分组标题: ID 缩短展示,完整值放在 title */
function chatFilesGroupHeadingConversation(key) { function chatFilesGroupHeadingID(key, emptyLabel) {
const c = key == null ? '' : String(key); const c = key == null ? '' : String(key);
if (c === '' || c === '—') { if (c === '' || c === '—') {
return { text: '—', title: '' }; return { text: emptyLabel || '—', title: '' };
} }
if (c.length > 36) {
return { text: c.slice(0, 8) + '…' + c.slice(-6), title: c };
}
return { text: c, title: c };
}
function chatFilesGroupHeadingConversation(key) {
const c = key == null ? '' : String(key);
if (typeof window.t === 'function') { if (typeof window.t === 'function') {
if (c === '_manual') { if (c === '_manual') {
return { text: window.t('chatFilesPage.convManual'), title: '_manual' }; return { text: window.t('chatFilesPage.convManual'), title: '_manual' };
@@ -637,10 +913,12 @@ function chatFilesGroupHeadingConversation(key) {
return { text: window.t('chatFilesPage.convNew'), title: '_new' }; return { text: window.t('chatFilesPage.convNew'), title: '_new' };
} }
} }
if (c.length > 36) { return chatFilesGroupHeadingID(key);
return { text: c.slice(0, 8) + '…' + c.slice(-6), title: c }; }
}
return { text: c, title: c }; function chatFilesGroupHeadingProject(key) {
const empty = (typeof window.t === 'function') ? window.t('chatFilesPage.projectUnbound') : '未绑定项目';
return chatFilesGroupHeadingID(key, empty);
} }
function renderChatFilesTable() { function renderChatFilesTable() {
@@ -658,6 +936,7 @@ function renderChatFilesTable() {
if (typeof window.applyTranslations === 'function') { if (typeof window.applyTranslations === 'function') {
window.applyTranslations(wrap); window.applyTranslations(wrap);
} }
renderChatFilesPagination();
return; return;
} }
@@ -683,6 +962,8 @@ function renderChatFilesTable() {
const rp = f.relativePath || ''; const rp = f.relativePath || '';
const pathForTitle = (f.absolutePath && String(f.absolutePath).trim()) ? String(f.absolutePath).trim() : rp; const pathForTitle = (f.absolutePath && String(f.absolutePath).trim()) ? String(f.absolutePath).trim() : rp;
const nameEsc = escapeHtml(f.name || ''); const nameEsc = escapeHtml(f.name || '');
const isInternal = chatFilesIsInternalSource(f);
const sourceBadge = isInternal ? '<span class="chat-files-source-badge">' + escapeHtml(chatFilesSourceLabel(f.source)) + '</span>' : '';
const conv = f.conversationId || ''; const conv = f.conversationId || '';
const convEsc = escapeHtml(conv); const convEsc = escapeHtml(conv);
const dt = f.modifiedUnix ? new Date(f.modifiedUnix * 1000).toLocaleString() : '—'; const dt = f.modifiedUnix ? new Date(f.modifiedUnix * 1000).toLocaleString() : '—';
@@ -700,13 +981,17 @@ function renderChatFilesTable() {
if (canOpenChat) { if (canOpenChat) {
menuParts.push(`<button type="button" class="chat-files-dropdown-item" onclick="chatFilesCloseAllMenus(); openChatFilesConversationIdx(${idx});">${tOpenChat}</button>`); menuParts.push(`<button type="button" class="chat-files-dropdown-item" onclick="chatFilesCloseAllMenus(); openChatFilesConversationIdx(${idx});">${tOpenChat}</button>`);
} }
if (!bin) { if (isInternal) {
menuParts.push(`<div class="chat-files-dropdown-item is-disabled">${escapeHtml(chatFilesSourceLabel(f.source))}</div>`);
} else if (!bin) {
menuParts.push(`<button type="button" class="chat-files-dropdown-item" onclick="chatFilesCloseAllMenus(); openChatFilesEditIdx(${idx});">${tEdit}</button>`); menuParts.push(`<button type="button" class="chat-files-dropdown-item" onclick="chatFilesCloseAllMenus(); openChatFilesEditIdx(${idx});">${tEdit}</button>`);
} else { } else {
menuParts.push(`<div class="chat-files-dropdown-item is-disabled" title="${editHint}">${editUnavailable}</div>`); menuParts.push(`<div class="chat-files-dropdown-item is-disabled" title="${editHint}">${editUnavailable}</div>`);
} }
menuParts.push(`<button type="button" class="chat-files-dropdown-item" onclick="chatFilesCloseAllMenus(); openChatFilesRenameIdx(${idx});">${tRename}</button>`); if (!isInternal) {
menuParts.push(`<button type="button" class="chat-files-dropdown-item is-danger" onclick="chatFilesCloseAllMenus(); deleteChatFileIdx(${idx});">${tDelete}</button>`); menuParts.push(`<button type="button" class="chat-files-dropdown-item" onclick="chatFilesCloseAllMenus(); openChatFilesRenameIdx(${idx});">${tRename}</button>`);
menuParts.push(`<button type="button" class="chat-files-dropdown-item is-danger" onclick="chatFilesCloseAllMenus(); deleteChatFileIdx(${idx});">${tDelete}</button>`);
}
const menuHtml = menuParts.join(''); const menuHtml = menuParts.join('');
const subRaw = (f.subPath && String(f.subPath).trim()) ? String(f.subPath).trim() : ''; const subRaw = (f.subPath && String(f.subPath).trim()) ? String(f.subPath).trim() : '';
@@ -728,7 +1013,7 @@ function renderChatFilesTable() {
<td>${escapeHtml(f.date || '—')}</td> <td>${escapeHtml(f.date || '—')}</td>
<td class="chat-files-cell-conv"><code title="${convEsc}">${convEsc}</code></td> <td class="chat-files-cell-conv"><code title="${convEsc}">${convEsc}</code></td>
<td class="chat-files-cell-subpath" title="${escapeHtml(subRaw || '')}">${subCellInner}</td> <td class="chat-files-cell-subpath" title="${escapeHtml(subRaw || '')}">${subCellInner}</td>
<td class="chat-files-cell-name" title="${escapeHtml(pathForTitle)}">${nameEsc}</td> <td class="chat-files-cell-name" title="${escapeHtml(pathForTitle)}">${nameEsc}${sourceBadge}</td>
<td>${formatChatFileBytes(f.size || 0)}</td> <td>${formatChatFileBytes(f.size || 0)}</td>
<td>${escapeHtml(dt)}</td> <td>${escapeHtml(dt)}</td>
<td class="chat-files-actions"> <td class="chat-files-actions">
@@ -774,11 +1059,11 @@ function renderChatFilesTable() {
return (chatFilesDisplayed[b.idx].modifiedUnix || 0) - (chatFilesDisplayed[a.idx].modifiedUnix || 0); return (chatFilesDisplayed[b.idx].modifiedUnix || 0) - (chatFilesDisplayed[a.idx].modifiedUnix || 0);
}); });
const tRoot = escapeHtml((typeof window.t === 'function') ? window.t('chatFilesPage.browseRoot') : 'chat_uploads'); const tRoot = escapeHtml((typeof window.t === 'function') ? window.t('chatFilesPage.browseRoot') : '文件');
const tUp = escapeHtml((typeof window.t === 'function') ? window.t('chatFilesPage.browseUp') : '上级'); const tUp = escapeHtml((typeof window.t === 'function') ? window.t('chatFilesPage.browseUp') : '上级');
const tMkdir = escapeHtml((typeof window.t === 'function') ? window.t('chatFilesPage.newFolderButton') : '新建文件夹'); const tMkdir = escapeHtml((typeof window.t === 'function') ? window.t('chatFilesPage.newFolderButton') : '新建文件夹');
const tEmpty = escapeHtml((typeof window.t === 'function') ? window.t('chatFilesPage.folderEmpty') : '此文件夹为空'); const tEmpty = escapeHtml((typeof window.t === 'function') ? window.t('chatFilesPage.folderEmpty') : '此文件夹为空');
const tCopyFolder = escapeHtml((typeof window.t === 'function') ? window.t('chatFilesPage.copyFolderPathTitle') : '复制 chat_uploads 下相对路径'); const tCopyFolder = escapeHtml((typeof window.t === 'function') ? window.t('chatFilesPage.copyFolderPathTitle') : '复制目录路径');
const tEnter = escapeHtml((typeof window.t === 'function') ? window.t('chatFilesPage.enterFolderTitle') : '进入'); const tEnter = escapeHtml((typeof window.t === 'function') ? window.t('chatFilesPage.enterFolderTitle') : '进入');
let breadcrumbHtml = '<nav class="chat-files-breadcrumb" aria-label="breadcrumb">'; let breadcrumbHtml = '<nav class="chat-files-breadcrumb" aria-label="breadcrumb">';
@@ -789,16 +1074,20 @@ function renderChatFilesTable() {
const isLast = bi === chatFilesBrowsePath.length - 1; const isLast = bi === chatFilesBrowsePath.length - 1;
breadcrumbHtml += '<span class="chat-files-breadcrumb-sep">/</span>'; breadcrumbHtml += '<span class="chat-files-breadcrumb-sep">/</span>';
if (isLast) { if (isLast) {
breadcrumbHtml += '<span class="chat-files-breadcrumb-current">' + escapeHtml(seg) + '</span>'; breadcrumbHtml += '<span class="chat-files-breadcrumb-current">' + escapeHtml(chatFilesTreeDisplayName(seg)) + '</span>';
} else { } else {
breadcrumbHtml += '<button type="button" class="chat-files-breadcrumb-link" onclick="chatFilesNavigateBreadcrumb(' + bi + ')">' + escapeHtml(seg) + '</button>'; breadcrumbHtml += '<button type="button" class="chat-files-breadcrumb-link" onclick="chatFilesNavigateBreadcrumb(' + bi + ')">' + escapeHtml(chatFilesTreeDisplayName(seg)) + '</button>';
} }
} }
breadcrumbHtml += '</nav>'; breadcrumbHtml += '</nav>';
const canMutateCurrentPath = chatFilesBrowseCanMutateCurrentPath();
const upDisabled = chatFilesBrowsePath.length === 0 ? ' disabled' : ''; const upDisabled = chatFilesBrowsePath.length === 0 ? ' disabled' : '';
const mkdirButton = canMutateCurrentPath
? '<button type="button" class="btn-secondary chat-files-mkdir-btn" onclick="openChatFilesMkdirModal()">' + tMkdir + '</button>'
: '';
const toolbarHtml = '<div class="chat-files-browse-toolbar">' + breadcrumbHtml + const toolbarHtml = '<div class="chat-files-browse-toolbar">' + breadcrumbHtml +
'<button type="button" class="btn-secondary chat-files-mkdir-btn" onclick="openChatFilesMkdirModal()">' + tMkdir + '</button>' + mkdirButton +
'<button type="button" class="btn-secondary chat-files-browse-up"' + upDisabled + ' onclick="chatFilesNavigateUp()">' + tUp + '</button></div>'; '<button type="button" class="btn-secondary chat-files-browse-up"' + upDisabled + ' onclick="chatFilesNavigateUp()">' + tUp + '</button></div>';
const svgTrash = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>'; const svgTrash = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>';
@@ -808,19 +1097,28 @@ function renderChatFilesTable() {
function rowHtmlBrowseFolder(name) { function rowHtmlBrowseFolder(name) {
const nameAttr = encodeURIComponent(String(name)); const nameAttr = encodeURIComponent(String(name));
const relToFolder = chatFilesBrowsePath.concat([name]).join('/'); const folderPath = chatFilesBrowsePath.concat([name]);
const relToFolder = folderPath.join('/');
const uploadDirAttr = encodeURIComponent(relToFolder); const uploadDirAttr = encodeURIComponent(relToFolder);
const canUploadFolder = chatFilesBrowseCanUploadToPath(folderPath);
const canDeleteFolder = chatFilesBrowseCanDeleteFolderPath(folderPath);
const uploadBtn = canUploadFolder
? `<button type="button" class="btn-icon" title="${tUploadToFolder}" data-upload-dir="${uploadDirAttr}" onclick="chatFilesUploadToFolderClick(event, this)">${svgUploadToFolder}</button>`
: '';
const deleteBtn = canDeleteFolder
? `<button type="button" class="btn-icon btn-danger" title="${tDeleteFolder}" data-chat-folder-name="${nameAttr}" onclick="chatFilesDeleteFolderFromBtn(event, this)">${svgTrash}</button>`
: '';
return `<tr class="chat-files-tr-folder chat-files-tr-folder--nav" role="button" tabindex="0" data-chat-folder-name="${nameAttr}" onclick="chatFilesOnFolderRowClick(event)" onkeydown="chatFilesOnFolderRowKeydown(event)"> return `<tr class="chat-files-tr-folder chat-files-tr-folder--nav" role="button" tabindex="0" data-chat-folder-name="${nameAttr}" onclick="chatFilesOnFolderRowClick(event)" onkeydown="chatFilesOnFolderRowKeydown(event)">
<td class="chat-files-tree-name-cell chat-files-tree-name-cell--folder" title="${tEnter}"> <td class="chat-files-tree-name-cell chat-files-tree-name-cell--folder" title="${tEnter}">
<span class="chat-files-tree-name-inner">${svgFolder}<span class="chat-files-tree-name-text">${escapeHtml(name)}</span></span> <span class="chat-files-tree-name-inner">${svgFolder}<span class="chat-files-tree-name-text">${escapeHtml(chatFilesTreeDisplayName(name))}</span></span>
</td> </td>
<td class="chat-files-tree-muted"></td> <td class="chat-files-tree-muted"></td>
<td class="chat-files-tree-muted"></td> <td class="chat-files-tree-muted"></td>
<td class="chat-files-actions" data-chat-files-stop="true" onclick="event.stopPropagation()"> <td class="chat-files-actions" data-chat-files-stop="true" onclick="event.stopPropagation()">
<div class="chat-files-action-bar"> <div class="chat-files-action-bar">
<button type="button" class="btn-icon" title="${tUploadToFolder}" data-upload-dir="${uploadDirAttr}" onclick="chatFilesUploadToFolderClick(event, this)">${svgUploadToFolder}</button> ${uploadBtn}
<button type="button" class="btn-icon" title="${tCopyFolder}" data-chat-folder-name="${nameAttr}" onclick="chatFilesCopyFolderPathFromBtn(event, this)">${svgCopy}</button> <button type="button" class="btn-icon" title="${tCopyFolder}" data-chat-folder-name="${nameAttr}" onclick="chatFilesCopyFolderPathFromBtn(event, this)">${svgCopy}</button>
<button type="button" class="btn-icon btn-danger" title="${tDeleteFolder}" data-chat-folder-name="${nameAttr}" onclick="chatFilesDeleteFolderFromBtn(event, this)">${svgTrash}</button> ${deleteBtn}
</div> </div>
</td> </td>
</tr>`; </tr>`;
@@ -829,6 +1127,8 @@ function renderChatFilesTable() {
function rowHtmlTreeFile(f, idx) { function rowHtmlTreeFile(f, idx) {
const pathForTitle = (f.absolutePath && String(f.absolutePath).trim()) ? String(f.absolutePath).trim() : (f.relativePath || ''); const pathForTitle = (f.absolutePath && String(f.absolutePath).trim()) ? String(f.absolutePath).trim() : (f.relativePath || '');
const nameEsc = escapeHtml(f.name || ''); const nameEsc = escapeHtml(f.name || '');
const isInternal = chatFilesIsInternalSource(f);
const sourceBadge = isInternal ? '<span class="chat-files-source-badge">' + escapeHtml(chatFilesSourceLabel(f.source)) + '</span>' : '';
const dt = f.modifiedUnix ? new Date(f.modifiedUnix * 1000).toLocaleString() : '—'; const dt = f.modifiedUnix ? new Date(f.modifiedUnix * 1000).toLocaleString() : '—';
const conv = f.conversationId || ''; const conv = f.conversationId || '';
const canOpenChat = conv && conv !== '_manual' && conv !== '_new'; const canOpenChat = conv && conv !== '_manual' && conv !== '_new';
@@ -845,18 +1145,22 @@ function renderChatFilesTable() {
if (canOpenChat) { if (canOpenChat) {
menuParts.push(`<button type="button" class="chat-files-dropdown-item" onclick="chatFilesCloseAllMenus(); openChatFilesConversationIdx(${idx});">${tOpenChat}</button>`); menuParts.push(`<button type="button" class="chat-files-dropdown-item" onclick="chatFilesCloseAllMenus(); openChatFilesConversationIdx(${idx});">${tOpenChat}</button>`);
} }
if (!bin) { if (isInternal) {
menuParts.push(`<div class="chat-files-dropdown-item is-disabled">${escapeHtml(chatFilesSourceLabel(f.source))}</div>`);
} else if (!bin) {
menuParts.push(`<button type="button" class="chat-files-dropdown-item" onclick="chatFilesCloseAllMenus(); openChatFilesEditIdx(${idx});">${tEdit}</button>`); menuParts.push(`<button type="button" class="chat-files-dropdown-item" onclick="chatFilesCloseAllMenus(); openChatFilesEditIdx(${idx});">${tEdit}</button>`);
} else { } else {
menuParts.push(`<div class="chat-files-dropdown-item is-disabled" title="${editHint}">${editUnavailable}</div>`); menuParts.push(`<div class="chat-files-dropdown-item is-disabled" title="${editHint}">${editUnavailable}</div>`);
} }
menuParts.push(`<button type="button" class="chat-files-dropdown-item" onclick="chatFilesCloseAllMenus(); openChatFilesRenameIdx(${idx});">${tRename}</button>`); if (!isInternal) {
menuParts.push(`<button type="button" class="chat-files-dropdown-item is-danger" onclick="chatFilesCloseAllMenus(); deleteChatFileIdx(${idx});">${tDelete}</button>`); menuParts.push(`<button type="button" class="chat-files-dropdown-item" onclick="chatFilesCloseAllMenus(); openChatFilesRenameIdx(${idx});">${tRename}</button>`);
menuParts.push(`<button type="button" class="chat-files-dropdown-item is-danger" onclick="chatFilesCloseAllMenus(); deleteChatFileIdx(${idx});">${tDelete}</button>`);
}
const menuHtml = menuParts.join(''); const menuHtml = menuParts.join('');
return `<tr class="chat-files-tr-file"> return `<tr class="chat-files-tr-file">
<td class="chat-files-tree-name-cell" title="${escapeHtml(pathForTitle)}"> <td class="chat-files-tree-name-cell" title="${escapeHtml(pathForTitle)}">
<span class="chat-files-tree-name-inner">${svgFile}<span class="chat-files-tree-name-text">${nameEsc}</span></span> <span class="chat-files-tree-name-inner">${svgFile}<span class="chat-files-tree-name-text">${nameEsc}${sourceBadge}</span></span>
</td> </td>
<td>${formatChatFileBytes(f.size || 0)}</td> <td>${formatChatFileBytes(f.size || 0)}</td>
<td>${escapeHtml(dt)}</td> <td>${escapeHtml(dt)}</td>
@@ -899,6 +1203,10 @@ function renderChatFilesTable() {
let summaryTitleAttr = ''; let summaryTitleAttr = '';
if (groupMode === 'date') { if (groupMode === 'date') {
summaryMain = escapeHtml(String(g.key)); summaryMain = escapeHtml(String(g.key));
} else if (groupMode === 'project') {
const h = chatFilesGroupHeadingProject(g.key);
summaryMain = escapeHtml(h.text);
summaryTitleAttr = h.title ? ' title="' + escapeHtml(h.title) + '"' : '';
} else { } else {
const h = chatFilesGroupHeadingConversation(g.key); const h = chatFilesGroupHeadingConversation(g.key);
summaryMain = escapeHtml(h.text); summaryMain = escapeHtml(h.text);
@@ -926,9 +1234,13 @@ function renderChatFilesTable() {
wrap.innerHTML = innerHtml; wrap.innerHTML = innerHtml;
wrap.classList.toggle('chat-files-table-wrap--grouped', groupMode !== 'none' && groupMode !== 'folder'); wrap.classList.toggle('chat-files-table-wrap--grouped', groupMode !== 'none' && groupMode !== 'folder');
wrap.classList.toggle('chat-files-table-wrap--tree', groupMode === 'folder'); wrap.classList.toggle('chat-files-table-wrap--tree', groupMode === 'folder');
renderChatFilesPagination();
} }
window.chatFilesGroupByChange = chatFilesGroupByChange; window.chatFilesGroupByChange = chatFilesGroupByChange;
window.changeChatFilesPage = changeChatFilesPage;
window.changeChatFilesPageSize = changeChatFilesPageSize;
window.chatFilesResetToFirstPageAndLoad = chatFilesResetToFirstPageAndLoad;
function chatFilesNavigateInto(name) { function chatFilesNavigateInto(name) {
const root = chatFilesTreeRootMerged(); const root = chatFilesTreeRootMerged();
@@ -989,7 +1301,8 @@ function chatFilesCopyFolderPathFromBtn(ev, btn) {
async function deleteChatFolderFromBrowse(folderName) { async function deleteChatFolderFromBrowse(folderName) {
const segs = chatFilesBrowsePath.concat([folderName]); const segs = chatFilesBrowsePath.concat([folderName]);
const rel = segs.join('/'); if (!chatFilesBrowseCanDeleteFolderPath(segs)) return;
const rel = chatFilesUploadRelativeDirFromBrowsePath(segs);
const q = (typeof window.t === 'function') ? window.t('chatFilesPage.confirmDeleteFolder') : '确定删除该文件夹及其中的全部文件?'; const q = (typeof window.t === 'function') ? window.t('chatFilesPage.confirmDeleteFolder') : '确定删除该文件夹及其中的全部文件?';
if (!confirm(q)) return; if (!confirm(q)) return;
try { try {
@@ -1038,8 +1351,7 @@ function chatFilesDeleteFolderFromBtn(ev, btn) {
async function copyChatFolderPathFromBrowse(folderName) { async function copyChatFolderPathFromBrowse(folderName) {
const segs = chatFilesBrowsePath.concat([folderName]); const segs = chatFilesBrowsePath.concat([folderName]);
const rel = segs.join('/'); const text = chatFilesClipboardFolderPath(segs);
const text = rel ? ('chat_uploads/' + rel.replace(/^\/+/, '')) : 'chat_uploads';
const ok = await chatFilesCopyText(text); const ok = await chatFilesCopyText(text);
if (ok) { if (ok) {
const msg = (typeof window.t === 'function') ? window.t('chatFilesPage.folderPathCopied') : '目录路径已复制'; const msg = (typeof window.t === 'function') ? window.t('chatFilesPage.folderPathCopied') : '目录路径已复制';
@@ -1050,6 +1362,22 @@ async function copyChatFolderPathFromBrowse(folderName) {
} }
} }
function chatFilesClipboardFolderPath(segs) {
const parts = Array.isArray(segs) ? segs.slice() : [];
if (!parts.length) return '';
const root = parts.shift();
if (root === CHAT_FILES_TREE_UPLOAD_ROOT) {
return ['chat_uploads'].concat(parts).join('/');
}
if (root === CHAT_FILES_TREE_REDUCTION_ROOT) {
return ['tool_outputs'].concat(parts).join('/');
}
if (root === CHAT_FILES_TREE_ARTIFACT_ROOT) {
return ['conversation_artifacts'].concat(parts).join('/');
}
return [root].concat(parts).map(chatFilesTreeDisplayName).join('/');
}
window.chatFilesNavigateInto = chatFilesNavigateInto; window.chatFilesNavigateInto = chatFilesNavigateInto;
window.chatFilesNavigateBreadcrumb = chatFilesNavigateBreadcrumb; window.chatFilesNavigateBreadcrumb = chatFilesNavigateBreadcrumb;
window.chatFilesNavigateUp = chatFilesNavigateUp; window.chatFilesNavigateUp = chatFilesNavigateUp;
@@ -1060,6 +1388,7 @@ window.chatFilesCopyFolderPathFromBtn = chatFilesCopyFolderPathFromBtn;
window.chatFilesDeleteFolderFromBtn = chatFilesDeleteFolderFromBtn; window.chatFilesDeleteFolderFromBtn = chatFilesDeleteFolderFromBtn;
window.chatFilesOpenUploadPicker = chatFilesOpenUploadPicker; window.chatFilesOpenUploadPicker = chatFilesOpenUploadPicker;
window.chatFilesUploadToFolderClick = chatFilesUploadToFolderClick; window.chatFilesUploadToFolderClick = chatFilesUploadToFolderClick;
window.exportChatFiles = exportChatFiles;
window.openChatFilesMkdirModal = openChatFilesMkdirModal; window.openChatFilesMkdirModal = openChatFilesMkdirModal;
window.closeChatFilesMkdirModal = closeChatFilesMkdirModal; window.closeChatFilesMkdirModal = closeChatFilesMkdirModal;
window.submitChatFilesMkdir = submitChatFilesMkdir; window.submitChatFilesMkdir = submitChatFilesMkdir;
@@ -1254,11 +1583,12 @@ async function submitChatFilesRename() {
function openChatFilesMkdirModal() { function openChatFilesMkdirModal() {
if (chatFilesGetGroupByMode() !== 'folder') return; if (chatFilesGetGroupByMode() !== 'folder') return;
if (!chatFilesBrowseCanMutateCurrentPath()) return;
const hint = document.getElementById('chat-files-mkdir-parent-hint'); const hint = document.getElementById('chat-files-mkdir-parent-hint');
const input = document.getElementById('chat-files-mkdir-input'); const input = document.getElementById('chat-files-mkdir-input');
const modal = document.getElementById('chat-files-mkdir-modal'); const modal = document.getElementById('chat-files-mkdir-modal');
const p = chatFilesBrowsePath.join('/'); const p = chatFilesBrowsePath.map(chatFilesTreeDisplayName).join('/');
if (hint) hint.textContent = p ? ('chat_uploads/' + p) : 'chat_uploads'; if (hint) hint.textContent = p || chatFilesTreeDisplayName(CHAT_FILES_TREE_UPLOAD_ROOT);
if (input) input.value = ''; if (input) input.value = '';
if (modal) openAppModal(modal); if (modal) openAppModal(modal);
if (modal && typeof window.applyTranslations === 'function') { if (modal && typeof window.applyTranslations === 'function') {
@@ -1289,7 +1619,7 @@ async function submitChatFilesMkdir() {
alert(msg); alert(msg);
return; return;
} }
const parent = chatFilesBrowsePath.join('/'); const parent = chatFilesUploadRelativeDirFromBrowsePath(chatFilesBrowsePath);
try { try {
const res = await apiFetch('/api/chat-uploads/mkdir', { const res = await apiFetch('/api/chat-uploads/mkdir', {
method: 'POST', method: 'POST',
@@ -1355,7 +1685,8 @@ function chatFilesSetUploadBusy(busy) {
function chatFilesOpenUploadPicker() { function chatFilesOpenUploadPicker() {
if (chatFilesXHRUploadBusy) return; if (chatFilesXHRUploadBusy) return;
if (chatFilesGetGroupByMode() === 'folder') { if (chatFilesGetGroupByMode() === 'folder') {
chatFilesPendingUploadDir = chatFilesBrowsePath.join('/'); if (!chatFilesBrowseCanMutateCurrentPath()) return;
chatFilesPendingUploadDir = chatFilesUploadRelativeDirFromBrowsePath(chatFilesBrowsePath);
} else { } else {
chatFilesPendingUploadDir = ''; chatFilesPendingUploadDir = '';
} }
@@ -1370,6 +1701,7 @@ function chatFilesUploadToFolderClick(ev, btn) {
if (!raw) return; if (!raw) return;
try { try {
chatFilesPendingUploadDir = decodeURIComponent(raw); chatFilesPendingUploadDir = decodeURIComponent(raw);
chatFilesPendingUploadDir = chatFilesUploadRelativeDirFromBrowsePath(chatFilesPendingUploadDir.split('/').filter(Boolean));
} catch (e) { } catch (e) {
chatFilesPendingUploadDir = ''; chatFilesPendingUploadDir = '';
return; return;
@@ -1385,8 +1717,9 @@ function chatFilesResolveUploadTarget() {
return { relativeDir: pendingDir }; return { relativeDir: pendingDir };
} }
if (chatFilesGetGroupByMode() === 'folder') { if (chatFilesGetGroupByMode() === 'folder') {
const dir = chatFilesBrowsePath.join('/'); if (!chatFilesBrowseCanMutateCurrentPath()) return {};
return dir ? { relativeDir: dir } : {}; const relDir = chatFilesUploadRelativeDirFromBrowsePath(chatFilesBrowsePath);
return relDir ? { relativeDir: relDir } : {};
} }
const conv = document.getElementById('chat-files-filter-conv'); const conv = document.getElementById('chat-files-filter-conv');
if (conv && conv.value.trim()) { if (conv && conv.value.trim()) {
+52 -44
View File
@@ -2982,8 +2982,9 @@ function prefetchProcessDetailsSummaryHint(messageId, messageElement) {
if (summaryMcpIds.length > 0) { if (summaryMcpIds.length > 0) {
setPendingMcpExecutionIds(messageElement, summaryMcpIds); setPendingMcpExecutionIds(messageElement, summaryMcpIds);
} }
if (toolCount > 0) { const buttonToolCount = summaryTools.length > 0 ? summaryTools.length : (toolCount || summaryMcpIds.length);
setMcpExecutionSummaryCount(messageElement, toolCount); if (buttonToolCount > 0) {
setMcpExecutionSummaryCount(messageElement, buttonToolCount);
} }
const timeline = detailsContainer.querySelector('.progress-timeline'); const timeline = detailsContainer.querySelector('.progress-timeline');
if (!timeline || detailsContainer.dataset.loaded === '1') return; if (!timeline || detailsContainer.dataset.loaded === '1') return;
@@ -3082,8 +3083,6 @@ function getPendingToolExecutionSummaryCount(messageElement) {
} }
function getMcpExecutionCount(messageElement) { function getMcpExecutionCount(messageElement) {
const pending = getPendingMcpExecutionCount(messageElement);
if (pending > 0) return pending;
const pendingSummaries = getPendingToolExecutionSummaryCount(messageElement); const pendingSummaries = getPendingToolExecutionSummaryCount(messageElement);
if (pendingSummaries > 0) return pendingSummaries; if (pendingSummaries > 0) return pendingSummaries;
const toolList = messageElement && messageElement.querySelector('.mcp-tool-list'); const toolList = messageElement && messageElement.querySelector('.mcp-tool-list');
@@ -3095,6 +3094,8 @@ function getMcpExecutionCount(messageElement) {
const summaryCount = parseInt(messageElement.dataset.mcpExecutionCount, 10) || 0; const summaryCount = parseInt(messageElement.dataset.mcpExecutionCount, 10) || 0;
if (summaryCount > 0) return summaryCount; if (summaryCount > 0) return summaryCount;
} }
const pending = getPendingMcpExecutionCount(messageElement);
if (pending > 0) return pending;
return 0; return 0;
} }
@@ -3185,6 +3186,10 @@ function setPendingMcpExecutionIds(messageElement, executionIds) {
} else { } else {
delete messageElement.dataset.pendingMcpExecutionIds; delete messageElement.dataset.pendingMcpExecutionIds;
} }
const renderedToolList = messageElement.querySelector('.mcp-tool-list');
if (ids.length > 0 && renderedToolList && renderedToolList.querySelector('.mcp-detail-btn')) {
renderMcpCallButtons(messageElement);
}
if (typeof syncMcpToolsToggleButton === 'function') { if (typeof syncMcpToolsToggleButton === 'function') {
syncMcpToolsToggleButton(messageElement); syncMcpToolsToggleButton(messageElement);
} }
@@ -3248,6 +3253,18 @@ function mergeToolExecutionSummariesWithIds(summaries, executionIds) {
}); });
} }
function selectToolExecutionSummariesForButtons(summaries, executionIds) {
const normalizedSummaries = Array.isArray(summaries)
? summaries.map(normalizeToolExecutionSummaryForButton)
: [];
const normalizedIds = normalizeMcpExecutionIds(executionIds);
if (normalizedIds.length === 0) return normalizedSummaries;
if (normalizedSummaries.length === 0) {
return normalizedIds.map((executionId) => normalizeToolExecutionSummaryForButton({ executionId }));
}
return mergeToolExecutionSummariesWithIds(normalizedSummaries, normalizedIds);
}
function setPendingToolExecutionSummaries(messageElement, summaries) { function setPendingToolExecutionSummaries(messageElement, summaries) {
if (!messageElement || !messageElement.dataset || !Array.isArray(summaries)) return; if (!messageElement || !messageElement.dataset || !Array.isArray(summaries)) return;
const normalized = cacheToolExecutionSummaries(messageElement, summaries); const normalized = cacheToolExecutionSummaries(messageElement, summaries);
@@ -3260,7 +3277,6 @@ function setPendingToolExecutionSummaries(messageElement, summaries) {
if (normalized.length > 0 && renderedToolList && renderedToolList.querySelector('.mcp-detail-btn')) { if (normalized.length > 0 && renderedToolList && renderedToolList.querySelector('.mcp-detail-btn')) {
setMcpCallSummaries(messageElement, normalized); setMcpCallSummaries(messageElement, normalized);
delete messageElement.dataset.pendingToolExecutionSummaries; delete messageElement.dataset.pendingToolExecutionSummaries;
delete messageElement.dataset.pendingMcpExecutionIds;
} }
if (typeof syncMcpToolsToggleButton === 'function') { if (typeof syncMcpToolsToggleButton === 'function') {
syncMcpToolsToggleButton(messageElement); syncMcpToolsToggleButton(messageElement);
@@ -3550,6 +3566,15 @@ async function fetchProcessDetailDataForModal(detailId) {
return detail && detail.data ? detail.data : null; return detail && detail.data ? detail.data : null;
} }
function processToolResultTextFromData(resultData) {
if (!resultData) return '';
const noResultText = typeof window.t === 'function' ? window.t('timeline.noResult') : '无结果';
const result = resultData.result != null
? resultData.result
: (resultData.error != null ? resultData.error : (resultData.resultPreview != null ? resultData.resultPreview : noResultText));
return typeof result === 'string' ? result : JSON.stringify(result);
}
function processToolResultToMCPResult(resultData, rawText) { function processToolResultToMCPResult(resultData, rawText) {
if (!resultData) return null; if (!resultData) return null;
const displayState = typeof window.getToolResultDisplayState === 'function' const displayState = typeof window.getToolResultDisplayState === 'function'
@@ -3558,9 +3583,7 @@ function processToolResultToMCPResult(resultData, rawText) {
const isError = !!displayState.isError; const isError = !!displayState.isError;
let text = rawText; let text = rawText;
if (text == null || String(text) === '') { if (text == null || String(text) === '') {
const noResultText = typeof window.t === 'function' ? window.t('timeline.noResult') : '无结果'; text = processToolResultTextFromData(resultData);
const result = resultData.result != null ? resultData.result : (resultData.error != null ? resultData.error : (resultData.resultPreview != null ? resultData.resultPreview : noResultText));
text = typeof result === 'string' ? result : JSON.stringify(result);
} }
return { return {
content: [{ type: 'text', text: String(text || '') }], content: [{ type: 'text', text: String(text || '') }],
@@ -3591,8 +3614,14 @@ async function showMCPDetailFromProcessToolItem(messageElement, summary, index)
} }
} }
const resultDetailId = state.resultDetailId || target.dataset.toolResultDetailId || ''; const resultDetailId = state.resultDetailId || target.dataset.toolResultDetailId || '';
if (!resultData && resultDetailId) { const resultPayloadDeferred = resultData && resultData._payloadDeferred === true;
resultData = await fetchProcessDetailDataForModal(resultDetailId); const resultOnlyHasPreview = resultData && resultData.result == null && resultData.error == null && resultData.resultPreview != null;
if (resultDetailId && (!resultData || resultPayloadDeferred || resultOnlyHasPreview)) {
const fullResult = await fetchProcessDetailDataForModal(resultDetailId);
if (fullResult) {
resultData = fullResult;
rawText = processToolResultTextFromData(fullResult);
}
} }
const toolName = (summary && summary.toolName) || target.dataset.toolName || (typeof window.t === 'function' ? window.t('chat.unknownTool') : '未知工具'); const toolName = (summary && summary.toolName) || target.dataset.toolName || (typeof window.t === 'function' ? window.t('chat.unknownTool') : '未知工具');
const displayState = resultData && typeof window.getToolResultDisplayState === 'function' const displayState = resultData && typeof window.getToolResultDisplayState === 'function'
@@ -3639,7 +3668,7 @@ async function openTaskToolExecutionDetail(messageElement, item, index) {
/** /**
* 声明式渲染工具调用列表 * 声明式渲染工具调用列表
* 过程摘要是展示详情入口的唯一模型executionIds 仅在摘要尚未到达时提供占位 * 过程摘要提供完整展示入口executionIds 只补充可直接打开监控详情的 ID
* 每次更新整体替换列表避免增量追加产生双重状态 * 每次更新整体替换列表避免增量追加产生双重状态
*/ */
function renderMcpCallButtons(messageElement) { function renderMcpCallButtons(messageElement) {
@@ -3649,9 +3678,7 @@ function renderMcpCallButtons(messageElement) {
const toolList = chrome.toolList; const toolList = chrome.toolList;
const executionIds = getCachedMcpExecutionIds(messageElement); const executionIds = getCachedMcpExecutionIds(messageElement);
const summaries = getCachedToolExecutionSummaries(messageElement); const summaries = getCachedToolExecutionSummaries(messageElement);
const items = summaries.length > 0 const items = selectToolExecutionSummariesForButtons(summaries, executionIds);
? mergeToolExecutionSummariesWithIds(summaries, executionIds)
: executionIds.map((executionId) => normalizeToolExecutionSummaryForButton({ executionId }));
const renderVersion = String((parseInt(toolList.dataset.renderVersion, 10) || 0) + 1); const renderVersion = String((parseInt(toolList.dataset.renderVersion, 10) || 0) + 1);
toolList.dataset.renderVersion = renderVersion; toolList.dataset.renderVersion = renderVersion;
@@ -3718,7 +3745,6 @@ function renderPendingMcpCallButtons(messageElement) {
delete messageElement.dataset.pendingToolExecutionSummaries; delete messageElement.dataset.pendingToolExecutionSummaries;
} }
if (renderedSummaryExecutions) { if (renderedSummaryExecutions) {
delete messageElement.dataset.pendingMcpExecutionIds;
return; return;
} }
if (messageElement.dataset.pendingMcpExecutionIds) { if (messageElement.dataset.pendingMcpExecutionIds) {
@@ -3825,9 +3851,6 @@ async function batchUpdateButtonToolNames(buttonsContainer, executionIds, render
} }
} }
// 显示MCP调用详情
const MCP_DETAIL_MAX_CHARS = 120000;
function extractMCPResultText(result) { function extractMCPResultText(result) {
if (!result) return ''; if (!result) return '';
const content = result.content; const content = result.content;
@@ -3844,37 +3867,18 @@ function extractMCPResultText(result) {
return ''; return '';
} }
function truncateMCPDetailText(text, maxChars) { function formatMCPDetailText(text) {
if (text == null) return ''; if (text == null) return '';
const s = String(text); return String(text);
if (s.length <= maxChars) return s;
const hint = typeof window.t === 'function'
? window.t('mcpDetailModal.contentTruncated')
: '…(展示已截断;完整内容见 persisted-output 中的文件路径,用 read_file 读取)';
return s.slice(0, maxChars) + '\n\n' + hint;
} }
/** 响应结果区 JSON 展示(过大时截断 content 内 text,避免 stringify 卡死页面) */ function formatMCPResultJsonForDisplay(result) {
function formatMCPResultJsonForDisplay(result, maxChars) {
if (!result) return '{}'; if (!result) return '{}';
const payload = { const payload = {
content: result.content, content: result.content,
isError: !!result.isError isError: !!result.isError
}; };
let json = JSON.stringify(payload, null, 2); return JSON.stringify(payload, null, 2);
if (json.length <= maxChars) {
return json;
}
const text = extractMCPResultText(result);
const truncatedPayload = {
content: [{ type: 'text', text: truncateMCPDetailText(text, Math.min(maxChars - 800, MCP_DETAIL_MAX_CHARS)) }],
isError: !!result.isError
};
json = JSON.stringify(truncatedPayload, null, 2);
if (json.length > maxChars) {
return json.slice(0, maxChars) + '\n…';
}
return json;
} }
function switchMCPResultDetailTab(tabName) { function switchMCPResultDetailTab(tabName) {
@@ -3962,12 +3966,12 @@ function renderMCPDetailModal(exec) {
setMCPResultDetailTabs('raw', false); setMCPResultDetailTabs('raw', false);
if (exec.result) { if (exec.result) {
const agentVisibleText = truncateMCPDetailText(extractMCPResultText(exec.result), MCP_DETAIL_MAX_CHARS); const agentVisibleText = formatMCPDetailText(extractMCPResultText(exec.result));
const emptyText = typeof window.t === 'function' ? window.t('mcpDetailModal.execSuccessNoContent') : '执行成功,未返回可展示的文本内容。'; const emptyText = typeof window.t === 'function' ? window.t('mcpDetailModal.execSuccessNoContent') : '执行成功,未返回可展示的文本内容。';
if (exec.result.isError) { if (exec.result.isError) {
responseElement.className = 'code-block error'; responseElement.className = 'code-block error';
responseElement.textContent = formatMCPResultJsonForDisplay(exec.result, MCP_DETAIL_MAX_CHARS); responseElement.textContent = formatMCPResultJsonForDisplay(exec.result);
if (successElement) { if (successElement) {
successElement.textContent = ''; successElement.textContent = '';
} }
@@ -3978,7 +3982,7 @@ function renderMCPDetailModal(exec) {
} }
} else { } else {
responseElement.className = 'code-block'; responseElement.className = 'code-block';
responseElement.textContent = formatMCPResultJsonForDisplay(exec.result, MCP_DETAIL_MAX_CHARS); responseElement.textContent = formatMCPResultJsonForDisplay(exec.result);
if (successElement) { if (successElement) {
successElement.textContent = agentVisibleText || emptyText; successElement.textContent = agentVisibleText || emptyText;
} }
@@ -4528,6 +4532,10 @@ async function loadConversation(conversationId) {
// 更新当前对话ID // 更新当前对话ID
currentConversationId = conversationId; currentConversationId = conversationId;
window._loadedConversationProjectId = conversation.projectId || conversation.project_id || ''; window._loadedConversationProjectId = conversation.projectId || conversation.project_id || '';
const conversationRoleName = conversation.roleName || conversation.role_name || '';
if (typeof window.setCurrentRole === 'function') {
window.setCurrentRole(conversationRoleName || '默认');
}
try { try {
window.currentConversationId = conversationId; window.currentConversationId = conversationId;
} catch (e) { /* ignore */ } } catch (e) { /* ignore */ }
+13 -9
View File
@@ -295,14 +295,18 @@ async function refreshDashboard() {
toolsTotalCalls = s.totalCalls || 0; toolsTotalCalls = s.totalCalls || 0;
toolsFailedCount = s.failedCalls || 0; toolsFailedCount = s.failedCalls || 0;
const totalSuccess = s.successCalls || 0; const totalSuccess = s.successCalls || 0;
const effectiveToolCalls = totalSuccess + toolsFailedCount;
setEl('dashboard-kpi-tools-calls', formatNumber(toolsTotalCalls)); setEl('dashboard-kpi-tools-calls', formatNumber(toolsTotalCalls));
setKpiSubText('dashboard-kpi-tools-sub-text', setKpiSubText('dashboard-kpi-tools-sub-text',
dt('dashboard.toolsCountLabel', { count: toolsCount }, toolsCount + ' 个工具')); dt('dashboard.toolsCountLabel', { count: toolsCount }, toolsCount + ' 个工具'));
if (toolsTotalCalls > 0) { if (effectiveToolCalls > 0) {
toolsSuccessRate = (totalSuccess / toolsTotalCalls) * 100; toolsSuccessRate = (totalSuccess / effectiveToolCalls) * 100;
const rateStr = toolsSuccessRate.toFixed(1) + '%'; const rateStr = toolsSuccessRate.toFixed(1) + '%';
setEl('dashboard-kpi-success-rate', rateStr); setEl('dashboard-kpi-success-rate', rateStr);
setKpiRateBadge('dashboard-kpi-rate-sub-text', toolsSuccessRate, toolsFailedCount); setKpiRateBadge('dashboard-kpi-rate-sub-text', toolsSuccessRate, toolsFailedCount);
} else if (toolsTotalCalls > 0) {
setEl('dashboard-kpi-success-rate', '-');
setKpiSubText('dashboard-kpi-rate-sub-text', dt('dashboard.noCompletedYet', null, '暂无有效完成'));
} else { } else {
setEl('dashboard-kpi-success-rate', '-'); setEl('dashboard-kpi-success-rate', '-');
setKpiSubText('dashboard-kpi-rate-sub-text', dt('dashboard.noCallYet', null, '暂无调用')); setKpiSubText('dashboard-kpi-rate-sub-text', dt('dashboard.noCallYet', null, '暂无调用'));
@@ -1500,19 +1504,19 @@ function renderVulnStatusPanel(byStatus, total) {
setEl('dashboard-status-fp', formatNumber(fp)); setEl('dashboard-status-fp', formatNumber(fp));
setEl('dashboard-status-ignored', formatNumber(ignored)); setEl('dashboard-status-ignored', formatNumber(ignored));
// 修复率fixed / total(不计入 false_positive 时也可,按 total 维持一致) // 修复率只按需要处置的有效漏洞计算;误报/已忽略属于中性闭环,不拉低修复率。
var t = Number(total || 0); var actionableTotal = open + confirmed + fixed;
var rate = t > 0 ? (fixed / t) * 100 : 0; var rate = actionableTotal > 0 ? (fixed / actionableTotal) * 100 : 0;
var rateStr = t > 0 ? rate.toFixed(rate >= 100 ? 0 : 1) + '%' : '-'; var rateStr = actionableTotal > 0 ? rate.toFixed(rate >= 100 ? 0 : 1) + '%' : '-';
setEl('dashboard-fix-rate', rateStr); setEl('dashboard-fix-rate', rateStr);
var detailEl = document.getElementById('dashboard-fix-detail'); var detailEl = document.getElementById('dashboard-fix-detail');
if (detailEl) { if (detailEl) {
detailEl.textContent = '(' + formatNumber(fixed) + ' / ' + formatNumber(t) + ')'; detailEl.textContent = '(' + formatNumber(fixed) + ' / ' + formatNumber(actionableTotal) + ')';
} }
var fixedPct = t > 0 ? (fixed / t) * 100 : 0; var fixedPct = actionableTotal > 0 ? (fixed / actionableTotal) * 100 : 0;
var confirmedPct = t > 0 ? (confirmed / t) * 100 : 0; var confirmedPct = actionableTotal > 0 ? (confirmed / actionableTotal) * 100 : 0;
var fixedBar = document.getElementById('dashboard-fix-progress-fixed'); var fixedBar = document.getElementById('dashboard-fix-progress-fixed');
var confirmedBar = document.getElementById('dashboard-fix-progress-confirmed'); var confirmedBar = document.getElementById('dashboard-fix-progress-confirmed');
if (fixedBar) fixedBar.style.width = fixedPct.toFixed(2) + '%'; if (fixedBar) fixedBar.style.width = fixedPct.toFixed(2) + '%';
+21 -8
View File
@@ -5293,10 +5293,14 @@ function getMcpMonitorTimelineRange() {
function buildMonitorTotals(summary) { function buildMonitorTotals(summary) {
const s = summary && typeof summary === 'object' ? summary : {}; const s = summary && typeof summary === 'object' ? summary : {};
const total = s.totalCalls || 0;
const success = s.successCalls || 0;
const failed = s.failedCalls || 0;
return { return {
total: s.totalCalls || 0, total,
success: s.successCalls || 0, success,
failed: s.failedCalls || 0, failed,
neutral: Math.max(0, total - success - failed),
lastCallTime: s.lastCallTime ? new Date(s.lastCallTime) : null, lastCallTime: s.lastCallTime ? new Date(s.lastCallTime) : null,
}; };
} }
@@ -6193,7 +6197,11 @@ function renderMcpStatsMetricsBar(totals, successRate, rateTone, rateSubText, la
const lastCallLabel = mcpMonitorT('lastCall') || monitorFallback('最近一次调用', 'Last call'); const lastCallLabel = mcpMonitorT('lastCall') || monitorFallback('最近一次调用', 'Last call');
const successPill = mcpMonitorT('successCount', { n: totals.success }) || monitorFallback(`成功 ${totals.success}`, `Success ${totals.success}`); const successPill = mcpMonitorT('successCount', { n: totals.success }) || monitorFallback(`成功 ${totals.success}`, `Success ${totals.success}`);
const failedPill = mcpMonitorT('failedCount', { n: totals.failed }) || monitorFallback(`失败 ${totals.failed}`, `Failed ${totals.failed}`); const failedPill = mcpMonitorT('failedCount', { n: totals.failed }) || monitorFallback(`失败 ${totals.failed}`, `Failed ${totals.failed}`);
const neutralPill = mcpMonitorT('neutralCount', { n: totals.neutral }) || monitorFallback(`终止 ${totals.neutral}`, `Stopped ${totals.neutral}`);
const rateValue = hasCalls ? `${successRate}%` : successRate; const rateValue = hasCalls ? `${successRate}%` : successRate;
const neutralChip = totals.neutral > 0
? `<span class="mcp-stats-kpi__chip is-neutral">${escapeHtml(neutralPill)}</span>`
: '';
return ` return `
<div class="mcp-stats-kpi" role="group" aria-label="${escapeHtml(totalCallsLabel)}"> <div class="mcp-stats-kpi" role="group" aria-label="${escapeHtml(totalCallsLabel)}">
@@ -6205,6 +6213,7 @@ function renderMcpStatsMetricsBar(totals, successRate, rateTone, rateSubText, la
<div class="mcp-stats-kpi__meta"> <div class="mcp-stats-kpi__meta">
<span class="mcp-stats-kpi__chip is-ok">${escapeHtml(successPill)}</span> <span class="mcp-stats-kpi__chip is-ok">${escapeHtml(successPill)}</span>
<span class="mcp-stats-kpi__chip is-fail">${escapeHtml(failedPill)}</span> <span class="mcp-stats-kpi__chip is-fail">${escapeHtml(failedPill)}</span>
${neutralChip}
</div> </div>
</div> </div>
</article> </article>
@@ -6240,7 +6249,8 @@ function renderMcpStatsToolTable(topTools, totals, activeToolFilter = '') {
const total = tool.totalCalls || 0; const total = tool.totalCalls || 0;
const success = tool.successCalls || 0; const success = tool.successCalls || 0;
const failed = tool.failedCalls || 0; const failed = tool.failedCalls || 0;
const toolRateNum = total > 0 ? (success / total) * 100 : 0; const effectiveTotal = success + failed;
const toolRateNum = effectiveTotal > 0 ? (success / effectiveTotal) * 100 : 0;
const toolRate = toolRateNum.toFixed(1); const toolRate = toolRateNum.toFixed(1);
const sharePct = totals.total > 0 ? ((total / totals.total) * 100).toFixed(1) : '0.0'; const sharePct = totals.total > 0 ? ((total / totals.total) * 100).toFixed(1) : '0.0';
const dotColor = MCP_STATS_DIST_COLORS[index % MCP_STATS_DIST_COLORS.length]; const dotColor = MCP_STATS_DIST_COLORS[index % MCP_STATS_DIST_COLORS.length];
@@ -6318,7 +6328,8 @@ function renderMcpStatsToolsPanel(topTools, totals, activeToolFilter = '') {
const total = tool.totalCalls || 0; const total = tool.totalCalls || 0;
const success = tool.successCalls || 0; const success = tool.successCalls || 0;
const failed = tool.failedCalls || 0; const failed = tool.failedCalls || 0;
const toolRateNum = total > 0 ? (success / total) * 100 : 0; const effectiveTotal = success + failed;
const toolRateNum = effectiveTotal > 0 ? (success / effectiveTotal) * 100 : 0;
const toolRate = toolRateNum.toFixed(1); const toolRate = toolRateNum.toFixed(1);
const sharePct = totals.total > 0 ? ((total / totals.total) * 100).toFixed(1) : '0.0'; const sharePct = totals.total > 0 ? ((total / totals.total) * 100).toFixed(1) : '0.0';
const color = MCP_STATS_DIST_COLORS[index % MCP_STATS_DIST_COLORS.length]; const color = MCP_STATS_DIST_COLORS[index % MCP_STATS_DIST_COLORS.length];
@@ -6453,17 +6464,19 @@ function renderMonitorStats(summary = null, topTools = [], lastFetchedAt = null)
return; return;
} }
const hasCalls = totals.total > 0; const effectiveTotal = totals.success + totals.failed;
const successRateNum = hasCalls ? (totals.success / totals.total) * 100 : 0; const hasCalls = effectiveTotal > 0;
const successRateNum = hasCalls ? (totals.success / effectiveTotal) * 100 : 0;
const successRate = hasCalls ? successRateNum.toFixed(1) : '-'; const successRate = hasCalls ? successRateNum.toFixed(1) : '-';
const locale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : 'en-US'; const locale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : 'en-US';
const noCallsYet = mcpMonitorT('noCallsYet') || monitorFallback('暂无调用', 'No calls yet'); const noCallsYet = mcpMonitorT('noCallsYet') || monitorFallback('暂无调用', 'No calls yet');
const noCompletedYet = mcpMonitorT('noCompletedYet') || monitorFallback('暂无有效完成', 'No completed outcomes yet');
const lastCallText = totals.lastCallTime const lastCallText = totals.lastCallTime
? (totals.lastCallTime.toLocaleString ? totals.lastCallTime.toLocaleString(locale) : String(totals.lastCallTime)) ? (totals.lastCallTime.toLocaleString ? totals.lastCallTime.toLocaleString(locale) : String(totals.lastCallTime))
: noCallsYet; : noCallsYet;
const rateTone = hasCalls ? getMcpStatsRateTone(successRateNum) : 'is-muted'; const rateTone = hasCalls ? getMcpStatsRateTone(successRateNum) : 'is-muted';
let rateSubText = noCallsYet; let rateSubText = totals.total > 0 ? noCompletedYet : noCallsYet;
if (hasCalls) { if (hasCalls) {
rateSubText = mcpMonitorT('rateHealthy') || monitorFallback('运行平稳', 'Running smoothly'); rateSubText = mcpMonitorT('rateHealthy') || monitorFallback('运行平稳', 'Running smoothly');
if (successRateNum < 80) rateSubText = mcpMonitorT('rateCritical') || monitorFallback('失败率偏高', 'High failure rate'); if (successRateNum < 80) rateSubText = mcpMonitorT('rateCritical') || monitorFallback('失败率偏高', 'High failure rate');
+2
View File
@@ -1892,6 +1892,7 @@ function getCurrentRole() {
// 暴露函数到全局作用域 // 暴露函数到全局作用域
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.getCurrentRole = getCurrentRole; window.getCurrentRole = getCurrentRole;
window.setCurrentRole = handleRoleChange;
window.toggleRoleSelectionPanel = toggleRoleSelectionPanel; window.toggleRoleSelectionPanel = toggleRoleSelectionPanel;
window.closeRoleSelectionPanel = closeRoleSelectionPanel; window.closeRoleSelectionPanel = closeRoleSelectionPanel;
window.closeRoleSelectModal = closeRoleSelectModal; window.closeRoleSelectModal = closeRoleSelectModal;
@@ -1905,6 +1906,7 @@ if (typeof window !== 'undefined') {
originalHandleRoleChange(roleName); originalHandleRoleChange(roleName);
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.currentSelectedRole = getCurrentRole(); window.currentSelectedRole = getCurrentRole();
window.setCurrentRole = handleRoleChange;
} }
}; };
} }
+19 -3
View File
@@ -2714,6 +2714,7 @@
<div class="page-header"> <div class="page-header">
<h2 data-i18n="chatFilesPage.title">文件管理</h2> <h2 data-i18n="chatFilesPage.title">文件管理</h2>
<div class="page-header-actions"> <div class="page-header-actions">
<button type="button" class="btn-secondary" id="chat-files-export-btn" data-require-permission="files:read" onclick="exportChatFiles()" data-i18n="chatFilesPage.export">导出当前结果</button>
<button type="button" class="btn-primary" id="chat-files-header-upload-btn" data-require-permission="files:write" onclick="chatFilesOpenUploadPicker()" data-i18n="chatFilesPage.upload">上传文件</button> <button type="button" class="btn-primary" id="chat-files-header-upload-btn" data-require-permission="files:write" onclick="chatFilesOpenUploadPicker()" data-i18n="chatFilesPage.upload">上传文件</button>
<input type="file" id="chat-files-upload-input" style="display:none" multiple onchange="onChatFilesUploadPick(event)" /> <input type="file" id="chat-files-upload-input" style="display:none" multiple onchange="onChatFilesUploadPick(event)" />
<button type="button" class="btn-secondary" id="chat-files-refresh-btn" 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>
@@ -2724,11 +2725,24 @@
<div class="tasks-filters chat-files-filters"> <div class="tasks-filters chat-files-filters">
<label> <label>
<span data-i18n="chatFilesPage.conversationFilter">会话 ID</span> <span data-i18n="chatFilesPage.conversationFilter">会话 ID</span>
<input type="text" id="chat-files-filter-conv" class="form-control" data-i18n="chatFilesPage.conversationPlaceholder" data-i18n-attr="placeholder" placeholder="留空表示全部" onkeydown="if(event.key==='Enter') loadChatFilesPage()" /> <input type="text" id="chat-files-filter-conv" class="form-control" data-i18n="chatFilesPage.conversationPlaceholder" data-i18n-attr="placeholder" placeholder="留空表示全部" onkeydown="if(event.key==='Enter') chatFilesResetToFirstPageAndLoad()" />
</label>
<label>
<span data-i18n="chatFilesPage.projectFilter">项目 ID</span>
<input type="text" id="chat-files-filter-project" class="form-control" data-i18n="chatFilesPage.projectPlaceholder" data-i18n-attr="placeholder" placeholder="留空表示全部" onkeydown="if(event.key==='Enter') chatFilesResetToFirstPageAndLoad()" />
</label> </label>
<label style="flex:1;min-width:180px;max-width:360px;"> <label style="flex:1;min-width:180px;max-width:360px;">
<span data-i18n="chatFilesPage.searchName">文件名</span> <span data-i18n="chatFilesPage.searchName">文件名</span>
<input type="text" id="chat-files-filter-name" class="form-control" data-i18n="chatFilesPage.searchNamePlaceholder" data-i18n-attr="placeholder" placeholder="筛选文件名" oninput="chatFilesFilterNameOnInput()" onkeydown="if(event.key==='Enter') loadChatFilesPage()" /> <input type="text" id="chat-files-filter-name" class="form-control" data-i18n="chatFilesPage.searchNamePlaceholder" data-i18n-attr="placeholder" placeholder="筛选文件名" oninput="chatFilesFilterNameOnInput()" onkeydown="if(event.key==='Enter') chatFilesResetToFirstPageAndLoad()" />
</label>
<label>
<span data-i18n="chatFilesPage.sourceFilter">来源</span>
<select id="chat-files-filter-source" class="form-control" onchange="chatFilesResetToFirstPageAndLoad()">
<option value="all" data-i18n="chatFilesPage.sourceAll">全部</option>
<option value="upload" data-i18n="chatFilesPage.sourceUpload">对话附件</option>
<option value="reduction" data-i18n="chatFilesPage.sourceReduction">工具输出</option>
<option value="conversation_artifact" data-i18n="chatFilesPage.sourceConversationArtifact">会话产物</option>
</select>
</label> </label>
<label> <label>
<span data-i18n="chatFilesPage.groupBy">分组</span> <span data-i18n="chatFilesPage.groupBy">分组</span>
@@ -2736,10 +2750,11 @@
<option value="none" data-i18n="chatFilesPage.groupNone">不分组</option> <option value="none" data-i18n="chatFilesPage.groupNone">不分组</option>
<option value="date" data-i18n="chatFilesPage.groupByDate">按日期</option> <option value="date" data-i18n="chatFilesPage.groupByDate">按日期</option>
<option value="conversation" data-i18n="chatFilesPage.groupByConversation">按会话</option> <option value="conversation" data-i18n="chatFilesPage.groupByConversation">按会话</option>
<option value="project" data-i18n="chatFilesPage.groupByProject">按项目</option>
<option value="folder" data-i18n="chatFilesPage.groupByFolder">按文件夹</option> <option value="folder" data-i18n="chatFilesPage.groupByFolder">按文件夹</option>
</select> </select>
</label> </label>
<button class="btn-secondary" type="button" onclick="loadChatFilesPage()" data-i18n="common.search">搜索</button> <button class="btn-secondary" type="button" onclick="chatFilesResetToFirstPageAndLoad()" data-i18n="common.search">搜索</button>
</div> </div>
<div id="chat-files-upload-progress" class="chat-upload-progress-row chat-upload-progress-row--files" hidden role="status" aria-live="polite"> <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> <div class="chat-upload-progress-track" aria-hidden="true"><div class="chat-upload-progress-fill" id="chat-files-upload-progress-fill"></div></div>
@@ -2748,6 +2763,7 @@
<div id="chat-files-list-wrap" class="chat-files-table-wrap"> <div id="chat-files-list-wrap" class="chat-files-table-wrap">
<div class="loading-spinner" data-i18n="common.loading">加载中…</div> <div class="loading-spinner" data-i18n="common.loading">加载中…</div>
</div> </div>
<div id="chat-files-pagination" class="chat-files-pagination pagination" hidden></div>
</div> </div>
</div> </div>