mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-08-02 08:58:43 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbc4ead040 | ||
|
|
4333cf1dd3 | ||
|
|
577c97aab0 | ||
|
|
93ab362b6f | ||
|
|
d9cb0b11c5 | ||
|
|
2d7f5322b3 | ||
|
|
3763da8773 | ||
|
|
3f2984b7c6 | ||
|
|
fbef2afd68 | ||
|
|
bccb324d2b | ||
|
|
b28fba3d68 | ||
|
|
8059b6d5b2 | ||
|
|
0821bb2911 | ||
|
|
e407f8203c | ||
|
|
a59253e828 | ||
|
|
4c989fbbe0 | ||
|
|
91239c9869 | ||
|
|
6892156b7c | ||
|
|
75489123f9 | ||
|
|
631ee6c447 | ||
|
|
7caf77683f | ||
|
|
fce4ffe3c4 | ||
|
|
75163f9269 | ||
|
|
06a9cea97d | ||
|
|
b62ba3b214 |
@@ -34,6 +34,7 @@ func main() {
|
||||
|
||||
// 注册工具
|
||||
executor.RegisterTools(mcpServer)
|
||||
mcp.RegisterExecutionControlTools(mcpServer, nil)
|
||||
|
||||
log.Logger.Info("MCP服务器(stdio模式)已启动,等待消息...")
|
||||
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
# ============================================
|
||||
|
||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||
version: "v1.7.6"
|
||||
version: "v1.7.7"
|
||||
# 服务器配置
|
||||
server:
|
||||
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
||||
|
||||
@@ -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 |
|
||||
| `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:
|
||||
|
||||
```text
|
||||
@@ -60,6 +67,8 @@ Typical flow:
|
||||
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
|
||||
|
||||
- User stop cancels running tools for the current conversation.
|
||||
|
||||
@@ -51,6 +51,13 @@ Agent 调用工具
|
||||
| `wait_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
|
||||
@@ -60,6 +67,8 @@ Agent 调用工具
|
||||
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
@@ -24,18 +24,17 @@ import (
|
||||
|
||||
// Agent AI代理
|
||||
type Agent struct {
|
||||
openAIClient *openai.Client
|
||||
config *config.OpenAIConfig
|
||||
agentConfig *config.AgentConfig
|
||||
mcpServer *mcp.Server
|
||||
externalMCPMgr *mcp.ExternalMCPManager // 外部MCP管理器
|
||||
logger *zap.Logger
|
||||
maxIterations int
|
||||
mu sync.RWMutex // 添加互斥锁以支持并发更新
|
||||
toolNameMapping map[string]string // 工具名称映射:OpenAI格式 -> 原始格式(用于外部MCP工具)
|
||||
currentConversationID string // 当前对话ID(用于自动传递给工具)
|
||||
promptBaseDir string // 解析 system_prompt_path 时相对路径的基准目录(通常为 config.yaml 所在目录)
|
||||
toolDescriptionMode string // 工具描述模式: "short" | "full",默认 short
|
||||
openAIClient *openai.Client
|
||||
config *config.OpenAIConfig
|
||||
agentConfig *config.AgentConfig
|
||||
mcpServer *mcp.Server
|
||||
externalMCPMgr *mcp.ExternalMCPManager // 外部MCP管理器
|
||||
logger *zap.Logger
|
||||
maxIterations int
|
||||
mu sync.RWMutex // 添加互斥锁以支持并发更新
|
||||
toolNameMapping map[string]string // 工具名称映射:OpenAI格式 -> 原始格式(用于外部MCP工具)
|
||||
promptBaseDir string // 解析 system_prompt_path 时相对路径的基准目录(通常为 config.yaml 所在目录)
|
||||
toolDescriptionMode string // 工具描述模式: "short" | "full",默认 short
|
||||
}
|
||||
|
||||
type agentConversationIDKey struct{}
|
||||
@@ -526,12 +525,6 @@ func (a *Agent) executeToolViaMCP(ctx context.Context, toolName string, args map
|
||||
// 如果是record_vulnerability工具,自动添加conversation_id
|
||||
if toolName == builtin.ToolRecordVulnerability {
|
||||
conversationID := agentConversationIDFromContext(ctx)
|
||||
if conversationID == "" {
|
||||
a.mu.RLock()
|
||||
conversationID = a.currentConversationID
|
||||
a.mu.RUnlock()
|
||||
}
|
||||
|
||||
if conversationID != "" {
|
||||
args["conversation_id"] = conversationID
|
||||
a.logger.Debug("自动添加conversation_id到record_vulnerability工具",
|
||||
@@ -769,16 +762,8 @@ func (a *Agent) ToolsForRole(roleTools []string) []Tool {
|
||||
|
||||
// ExecuteMCPToolForConversation 在指定会话上下文中执行 MCP 工具(行为与主 Agent 循环中的工具调用一致,如自动注入 conversation_id)。
|
||||
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 = mcp.WithMCPConversationID(ctx, conversationID)
|
||||
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)
|
||||
}
|
||||
|
||||
// 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。
|
||||
// 用于 Eino filesystem execute 等场景,使助手气泡「渗透测试详情」与常规 MCP 一致可点进监控。
|
||||
func (a *Agent) RecordLocalToolExecution(ctx context.Context, toolName string, args map[string]interface{}, resultText string, invokeErr error) string {
|
||||
|
||||
@@ -3,11 +3,13 @@ package agent
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
"cyberstrike-ai/internal/mcp/builtin"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -116,3 +118,93 @@ func TestAgentCancelRunningMCPToolsForConversation(t *testing.T) {
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1337,6 +1337,7 @@ func setupRoutes(
|
||||
|
||||
// 对话附件(chat_uploads)管理
|
||||
protected.GET("/chat-uploads", chatUploadsHandler.List)
|
||||
protected.GET("/chat-uploads/export", chatUploadsHandler.Export)
|
||||
protected.GET("/chat-uploads/download", chatUploadsHandler.Download)
|
||||
protected.GET("/chat-uploads/content", chatUploadsHandler.GetContent)
|
||||
protected.POST("/chat-uploads", chatUploadsHandler.Upload)
|
||||
|
||||
@@ -22,6 +22,7 @@ type Conversation struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
ProjectID string `json:"projectId,omitempty"`
|
||||
RoleName string `json:"roleName,omitempty"`
|
||||
Pinned bool `json:"pinned"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
@@ -57,29 +58,30 @@ func (db *DB) CreateConversationWithWebshell(webshellConnectionID, title string,
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
roleName := normalizeConversationRoleName(meta.RoleName)
|
||||
|
||||
var err error
|
||||
wsID := strings.TrimSpace(webshellConnectionID)
|
||||
switch {
|
||||
case wsID != "" && projectID != "":
|
||||
_, err = db.Exec(
|
||||
"INSERT INTO conversations (id, title, created_at, updated_at, webshell_connection_id, project_id) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
id, title, now, now, wsID, projectID,
|
||||
"INSERT INTO conversations (id, title, created_at, updated_at, webshell_connection_id, project_id, role_name) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
id, title, now, now, wsID, projectID, roleName,
|
||||
)
|
||||
case wsID != "":
|
||||
_, err = db.Exec(
|
||||
"INSERT INTO conversations (id, title, created_at, updated_at, webshell_connection_id) VALUES (?, ?, ?, ?, ?)",
|
||||
id, title, now, now, wsID,
|
||||
"INSERT INTO conversations (id, title, created_at, updated_at, webshell_connection_id, role_name) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
id, title, now, now, wsID, roleName,
|
||||
)
|
||||
case projectID != "":
|
||||
_, err = db.Exec(
|
||||
"INSERT INTO conversations (id, title, created_at, updated_at, project_id) VALUES (?, ?, ?, ?, ?)",
|
||||
id, title, now, now, projectID,
|
||||
"INSERT INTO conversations (id, title, created_at, updated_at, project_id, role_name) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
id, title, now, now, projectID, roleName,
|
||||
)
|
||||
default:
|
||||
_, err = db.Exec(
|
||||
"INSERT INTO conversations (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)",
|
||||
id, title, now, now,
|
||||
"INSERT INTO conversations (id, title, created_at, updated_at, role_name) VALUES (?, ?, ?, ?, ?)",
|
||||
id, title, now, now, roleName,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
@@ -90,6 +92,7 @@ func (db *DB) CreateConversationWithWebshell(webshellConnectionID, title string,
|
||||
ID: id,
|
||||
Title: title,
|
||||
ProjectID: projectID,
|
||||
RoleName: roleName,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
@@ -236,10 +239,11 @@ func (db *DB) GetConversation(id string) (*Conversation, error) {
|
||||
var pinned int
|
||||
|
||||
var projectID sql.NullString
|
||||
var roleName sql.NullString
|
||||
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,
|
||||
).Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID)
|
||||
).Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID, &roleName)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("对话不存在")
|
||||
@@ -249,6 +253,9 @@ func (db *DB) GetConversation(id string) (*Conversation, error) {
|
||||
if projectID.Valid {
|
||||
conv.ProjectID = strings.TrimSpace(projectID.String)
|
||||
}
|
||||
if roleName.Valid {
|
||||
conv.RoleName = normalizeConversationRoleName(roleName.String)
|
||||
}
|
||||
|
||||
// 尝试多种时间格式解析
|
||||
var err1, err2 error
|
||||
@@ -322,10 +329,11 @@ func (db *DB) GetConversationLite(id string) (*Conversation, error) {
|
||||
var pinned int
|
||||
|
||||
var projectID sql.NullString
|
||||
var roleName sql.NullString
|
||||
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,
|
||||
).Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID)
|
||||
).Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID, &roleName)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("对话不存在")
|
||||
@@ -335,6 +343,9 @@ func (db *DB) GetConversationLite(id string) (*Conversation, error) {
|
||||
if projectID.Valid {
|
||||
conv.ProjectID = strings.TrimSpace(projectID.String)
|
||||
}
|
||||
if roleName.Valid {
|
||||
conv.RoleName = normalizeConversationRoleName(roleName.String)
|
||||
}
|
||||
|
||||
// 尝试多种时间格式解析
|
||||
var err1, err2 error
|
||||
@@ -365,6 +376,26 @@ func (db *DB) GetConversationLite(id string) (*Conversation, error) {
|
||||
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 {
|
||||
if alias != "" {
|
||||
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")
|
||||
args = append(args, limit, offset)
|
||||
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+`
|
||||
`+orderClause+`
|
||||
LIMIT ? OFFSET ?`,
|
||||
@@ -505,7 +536,7 @@ func (db *DB) ListConversations(limit, offset int, search, sortBy, projectID str
|
||||
}
|
||||
args = append(args, limit, offset)
|
||||
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...,
|
||||
)
|
||||
}
|
||||
@@ -514,45 +545,7 @@ func (db *DB) ListConversations(limit, offset int, search, sortBy, projectID str
|
||||
return nil, fmt.Errorf("查询对话列表失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
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
|
||||
return scanConversationRows(rows)
|
||||
}
|
||||
|
||||
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")
|
||||
args = append(args, limit, offset)
|
||||
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+`
|
||||
`+orderClause+`
|
||||
LIMIT ? OFFSET ?`, args...)
|
||||
@@ -586,7 +579,7 @@ func (db *DB) ListConversationsForAccess(limit, offset int, search, sortBy, proj
|
||||
}
|
||||
args = append(args, limit, offset)
|
||||
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...)
|
||||
}
|
||||
if err != nil {
|
||||
@@ -603,12 +596,16 @@ func scanConversationRows(rows *sql.Rows) ([]*Conversation, error) {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
if projectID.Valid {
|
||||
conv.ProjectID = strings.TrimSpace(projectID.String)
|
||||
}
|
||||
if roleName.Valid {
|
||||
conv.RoleName = normalizeConversationRoleName(roleName.String)
|
||||
}
|
||||
var err1, err2 error
|
||||
conv.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05.999999999-07:00", createdAt)
|
||||
if err1 != nil {
|
||||
@@ -668,7 +665,7 @@ func (db *DB) ListUngroupedConversations(limit, offset int, sortBy, projectID st
|
||||
where, args = appendConversationProjectFilter(where, args, projectID, "c")
|
||||
args = append(args, limit, offset)
|
||||
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+`
|
||||
`+orderClause+`
|
||||
LIMIT ? OFFSET ?`,
|
||||
@@ -678,43 +675,7 @@ func (db *DB) ListUngroupedConversations(limit, offset int, sortBy, projectID st
|
||||
return nil, fmt.Errorf("查询未分组对话失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
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()
|
||||
return scanConversationRows(rows)
|
||||
}
|
||||
|
||||
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")
|
||||
args = append(args, limit, offset)
|
||||
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+`
|
||||
`+orderClause+`
|
||||
LIMIT ? OFFSET ?`,
|
||||
@@ -861,6 +822,19 @@ func (db *DB) einoReductionBaseDir() string {
|
||||
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 {
|
||||
if db == nil {
|
||||
return ""
|
||||
|
||||
@@ -5,6 +5,7 @@ type ConversationCreateMeta struct {
|
||||
Source string
|
||||
WebShellConnectionID string
|
||||
ProjectID string
|
||||
RoleName string
|
||||
ClientIP string
|
||||
SessionHint string
|
||||
}
|
||||
|
||||
@@ -183,6 +183,7 @@ func (db *DB) initTables() error {
|
||||
title TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
role_name TEXT NOT NULL DEFAULT '默认',
|
||||
last_react_input TEXT,
|
||||
last_react_output TEXT
|
||||
);`
|
||||
@@ -226,6 +227,10 @@ func (db *DB) initTables() error {
|
||||
start_time DATETIME NOT NULL,
|
||||
end_time DATETIME,
|
||||
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,
|
||||
conversation_id TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
@@ -954,6 +959,9 @@ func (db *DB) initTables() error {
|
||||
if err := db.migrateWorkflowRunsTable(); err != nil {
|
||||
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 {
|
||||
db.logger.Warn("迁移RBAC资源归属字段失败", zap.Error(err))
|
||||
}
|
||||
@@ -978,6 +986,23 @@ func (db *DB) migrateRobotUserSessionsTable() error {
|
||||
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.
|
||||
func (db *DB) migrateAssetsTable() error {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -43,11 +43,19 @@ func (db *DB) SaveToolExecution(exec *mcp.ToolExecution) error {
|
||||
if exec.Duration > 0 {
|
||||
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 := `
|
||||
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)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
(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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
|
||||
_, err = db.Exec(query,
|
||||
@@ -60,6 +68,10 @@ func (db *DB) SaveToolExecution(exec *mcp.ToolExecution) error {
|
||||
exec.StartTime,
|
||||
endTime,
|
||||
durationMs,
|
||||
sqlNullString(exec.PartialOutput),
|
||||
exec.PartialOutputBytes,
|
||||
partialTruncated,
|
||||
partialUpdatedAt,
|
||||
strings.TrimSpace(exec.OwnerUserID),
|
||||
strings.TrimSpace(exec.ConversationID),
|
||||
time.Now(),
|
||||
@@ -90,6 +102,13 @@ func (db *DB) UpdateToolExecutionResult(id string, result *mcp.ToolResult) error
|
||||
return err
|
||||
}
|
||||
|
||||
func sqlNullString(s string) sql.NullString {
|
||||
if s == "" {
|
||||
return sql.NullString{}
|
||||
}
|
||||
return sql.NullString{String: s, Valid: true}
|
||||
}
|
||||
|
||||
// CountToolExecutions 统计工具执行记录总数
|
||||
func (db *DB) CountToolExecutions(status, toolName string) (int, error) {
|
||||
return db.CountToolExecutionsForAccess(status, toolName, RBACListAccess{Scope: RBACScopeAll})
|
||||
@@ -267,7 +286,8 @@ type ToolStatsSummaryResult struct {
|
||||
TopTools []*mcp.ToolStats
|
||||
}
|
||||
|
||||
// LoadToolStatsSummary 聚合统计信息,仅返回汇总与 Top N 工具(避免全量 map 传输)
|
||||
// LoadToolStatsSummary 聚合统计信息,仅返回汇总与 Top N 工具(避免全量 map 传输)。
|
||||
// 监控页的失败口径只包含真实失败/异常终止;用户主动取消的 cancelled 保留在总调用中,不计入失败。
|
||||
func (db *DB) LoadToolStatsSummary(topN int) (*ToolStatsSummaryResult, error) {
|
||||
if topN <= 0 {
|
||||
topN = 6
|
||||
@@ -282,19 +302,19 @@ func (db *DB) LoadToolStatsSummary(topN int) (*ToolStatsSummaryResult, error) {
|
||||
|
||||
summaryQuery := `
|
||||
SELECT COUNT(*),
|
||||
COALESCE(SUM(total_calls), 0),
|
||||
COALESCE(SUM(success_calls), 0),
|
||||
COALESCE(SUM(failed_calls), 0),
|
||||
MAX(last_call_time)
|
||||
FROM tool_stats
|
||||
COALESCE(SUM(CASE WHEN status = 'completed' 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)
|
||||
FROM tool_executions
|
||||
`
|
||||
var lastCallRaw sql.NullString
|
||||
err := db.QueryRow(summaryQuery).Scan(
|
||||
&result.Summary.ToolCount,
|
||||
&result.Summary.TotalCalls,
|
||||
&result.Summary.SuccessCalls,
|
||||
&result.Summary.FailedCalls,
|
||||
&lastCallRaw,
|
||||
&result.Summary.ToolCount,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -310,9 +330,13 @@ func (db *DB) LoadToolStatsSummary(topN int) (*ToolStatsSummaryResult, error) {
|
||||
}
|
||||
|
||||
topQuery := `
|
||||
SELECT tool_name, total_calls, success_calls, failed_calls, last_call_time
|
||||
FROM tool_stats
|
||||
WHERE total_calls > 0
|
||||
SELECT tool_name,
|
||||
COUNT(*) AS total_calls,
|
||||
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
|
||||
LIMIT ?
|
||||
`
|
||||
@@ -324,7 +348,7 @@ func (db *DB) LoadToolStatsSummary(topN int) (*ToolStatsSummaryResult, error) {
|
||||
|
||||
for rows.Next() {
|
||||
var stat mcp.ToolStats
|
||||
var lastCallTime sql.NullTime
|
||||
var lastCallTime sql.NullString
|
||||
if err := rows.Scan(
|
||||
&stat.ToolName,
|
||||
&stat.TotalCalls,
|
||||
@@ -336,7 +360,8 @@ func (db *DB) LoadToolStatsSummary(topN int) (*ToolStatsSummaryResult, error) {
|
||||
continue
|
||||
}
|
||||
if lastCallTime.Valid {
|
||||
stat.LastCallTime = &lastCallTime.Time
|
||||
parsed := parseDBTime(lastCallTime.String)
|
||||
stat.LastCallTime = &parsed
|
||||
}
|
||||
result.TopTools = append(result.TopTools, &stat)
|
||||
}
|
||||
@@ -359,7 +384,7 @@ func (db *DB) LoadToolStatsSummaryForAccess(topN int, access RBACListAccess) (*T
|
||||
var lastCall sql.NullString
|
||||
err := db.QueryRow(`SELECT COUNT(*),
|
||||
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(
|
||||
&result.Summary.TotalCalls, &result.Summary.SuccessCalls, &result.Summary.FailedCalls,
|
||||
&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(*),
|
||||
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)...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -482,7 +507,9 @@ func appendToolExecutionAccessSQL(query string, args []interface{}, access RBACL
|
||||
// GetToolExecution 根据ID获取单条工具执行记录
|
||||
func (db *DB) GetToolExecution(id string) (*mcp.ToolExecution, error) {
|
||||
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
|
||||
WHERE id = ?
|
||||
`
|
||||
@@ -495,6 +522,8 @@ func (db *DB) GetToolExecution(id string) (*mcp.ToolExecution, error) {
|
||||
var errorText sql.NullString
|
||||
var endTime sql.NullTime
|
||||
var durationMs sql.NullInt64
|
||||
var partialTruncated int
|
||||
var partialUpdatedAt sql.NullTime
|
||||
|
||||
err := row.Scan(
|
||||
&exec.ID,
|
||||
@@ -506,6 +535,10 @@ func (db *DB) GetToolExecution(id string) (*mcp.ToolExecution, error) {
|
||||
&exec.StartTime,
|
||||
&endTime,
|
||||
&durationMs,
|
||||
&exec.PartialOutput,
|
||||
&exec.PartialOutputBytes,
|
||||
&partialTruncated,
|
||||
&partialUpdatedAt,
|
||||
&exec.OwnerUserID,
|
||||
&exec.ConversationID,
|
||||
)
|
||||
@@ -538,6 +571,10 @@ func (db *DB) GetToolExecution(id string) (*mcp.ToolExecution, error) {
|
||||
if durationMs.Valid {
|
||||
exec.Duration = time.Duration(durationMs.Int64) * time.Millisecond
|
||||
}
|
||||
exec.PartialOutputTruncated = partialTruncated != 0
|
||||
if partialUpdatedAt.Valid {
|
||||
exec.PartialOutputUpdatedAt = &partialUpdatedAt.Time
|
||||
}
|
||||
|
||||
return &exec, nil
|
||||
}
|
||||
@@ -815,7 +852,7 @@ func (db *DB) PurgeToolExecutionsBefore(cutoff time.Time) (int64, error) {
|
||||
}
|
||||
delta.totalCalls += count
|
||||
switch status {
|
||||
case "failed", "cancelled", "hard_timeout", "orphaned":
|
||||
case "failed", "hard_timeout", "orphaned":
|
||||
delta.failedCalls += count
|
||||
case "completed":
|
||||
delta.successCalls += count
|
||||
@@ -971,7 +1008,7 @@ func (db *DB) LoadCallsTimeline(since time.Time, dailyBuckets bool) ([]CallsTime
|
||||
query = `
|
||||
SELECT date(start_time, 'localtime') AS bucket,
|
||||
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
|
||||
WHERE start_time >= ?
|
||||
GROUP BY bucket
|
||||
@@ -981,7 +1018,7 @@ func (db *DB) LoadCallsTimeline(since time.Time, dailyBuckets bool) ([]CallsTime
|
||||
query = `
|
||||
SELECT strftime('%Y-%m-%d %H:00:00', start_time, 'localtime') AS bucket,
|
||||
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
|
||||
WHERE start_time >= ?
|
||||
GROUP BY bucket
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ func (db *DB) ListConversationsByProjectID(projectID string, limit, offset int)
|
||||
limit = 100
|
||||
}
|
||||
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 ?`,
|
||||
projectID, limit, offset,
|
||||
)
|
||||
@@ -99,12 +99,16 @@ func (db *DB) ListConversationsByProjectID(projectID string, limit, offset int)
|
||||
var createdAt, updatedAt string
|
||||
var pinned int
|
||||
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
|
||||
}
|
||||
if pid.Valid {
|
||||
conv.ProjectID = strings.TrimSpace(pid.String)
|
||||
}
|
||||
if roleName.Valid {
|
||||
conv.RoleName = normalizeConversationRoleName(roleName.String)
|
||||
}
|
||||
conv.CreatedAt = parseDBTime(createdAt)
|
||||
conv.UpdatedAt = parseDBTime(updatedAt)
|
||||
conv.Pinned = pinned != 0
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
@@ -21,8 +25,15 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
chatUploadsRootDirName = "chat_uploads"
|
||||
maxChatUploadEditBytes = 2 * 1024 * 1024 // 文本编辑上限
|
||||
chatUploadsRootDirName = "chat_uploads"
|
||||
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
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
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) {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
@@ -74,6 +143,46 @@ func (h *ChatUploadsHandler) absRoot() (string, error) {
|
||||
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 根下
|
||||
func (h *ChatUploadsHandler) resolveUnderChatUploads(relativePath string) (abs string, err error) {
|
||||
root, err := h.absRoot()
|
||||
@@ -105,30 +214,44 @@ type ChatUploadFileItem struct {
|
||||
RelativePath string `json:"relativePath"`
|
||||
AbsolutePath string `json:"absolutePath"` // 服务器上的绝对路径,便于在对话中引用(与附件落盘路径一致)
|
||||
Name string `json:"name"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Size int64 `json:"size"`
|
||||
ModifiedUnix int64 `json:"modifiedUnix"`
|
||||
Date string `json:"date"`
|
||||
ConversationID string `json:"conversationId"`
|
||||
ProjectID string `json:"projectId,omitempty"`
|
||||
// SubPath 为日期、会话目录之下的子路径(不含文件名),如 date/conv/a/b/file 则为 "a/b";无嵌套则为 ""。
|
||||
SubPath string `json:"subPath"`
|
||||
}
|
||||
|
||||
// List GET /api/chat-uploads
|
||||
func (h *ChatUploadsHandler) List(c *gin.Context) {
|
||||
conversationFilter := strings.TrimSpace(c.Query("conversation"))
|
||||
func (h *ChatUploadsHandler) conversationProjectID(conversationID string, cache map[string]string) string {
|
||||
conversationID = strings.TrimSpace(conversationID)
|
||||
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()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
return nil, nil, err
|
||||
}
|
||||
// 保证根目录存在,否则「按文件夹」浏览时无法 mkdir,且首次列表为空时界面无路径工具栏
|
||||
if err := os.MkdirAll(root, 0755); err != nil {
|
||||
h.logger.Warn("创建 chat_uploads 根目录失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
return nil, nil, err
|
||||
}
|
||||
var files []ChatUploadFileItem
|
||||
var folders []string
|
||||
projectCache := make(map[string]string)
|
||||
err = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
@@ -157,6 +280,7 @@ func (h *ChatUploadsHandler) List(c *gin.Context) {
|
||||
if len(parts) >= 3 {
|
||||
convID = parts[1]
|
||||
}
|
||||
projectID := h.conversationProjectID(convID, projectCache)
|
||||
var subPath string
|
||||
if len(parts) >= 4 {
|
||||
subPath = strings.Join(parts[2:len(parts)-1], "/")
|
||||
@@ -164,29 +288,32 @@ func (h *ChatUploadsHandler) List(c *gin.Context) {
|
||||
if conversationFilter != "" && convID != conversationFilter {
|
||||
return nil
|
||||
}
|
||||
if projectFilter != "" && projectID != projectFilter {
|
||||
return nil
|
||||
}
|
||||
absPath, _ := filepath.Abs(path)
|
||||
files = append(files, ChatUploadFileItem{
|
||||
RelativePath: relSlash,
|
||||
AbsolutePath: absPath,
|
||||
Name: d.Name(),
|
||||
Source: chatUploadSourceUpload,
|
||||
Size: info.Size(),
|
||||
ModifiedUnix: info.ModTime().Unix(),
|
||||
Date: dateStr,
|
||||
ConversationID: convID,
|
||||
ProjectID: projectID,
|
||||
SubPath: subPath,
|
||||
})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Warn("列举对话附件失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
return nil, nil, err
|
||||
}
|
||||
if conversationFilter != "" {
|
||||
if conversationFilter != "" || projectFilter != "" {
|
||||
filteredFolders := make([]string, 0, len(folders))
|
||||
for _, rel := range folders {
|
||||
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)
|
||||
continue
|
||||
}
|
||||
@@ -221,12 +348,480 @@ func (h *ChatUploadsHandler) List(c *gin.Context) {
|
||||
sort.Slice(files, func(i, j int) bool {
|
||||
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=...
|
||||
func (h *ChatUploadsHandler) Download(c *gin.Context) {
|
||||
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) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
|
||||
return
|
||||
|
||||
@@ -1517,6 +1517,7 @@ func (h *ConfigHandler) ApplyConfig(c *gin.Context) {
|
||||
h.executor.SetToolOutputMaxBytes(h.config.MultiAgent.EinoMiddleware.ReductionMaxLengthForTruncEffective())
|
||||
h.executor.SetToolOutputSpillRoot(h.config.MultiAgent.EinoMiddleware.ReductionRootDir)
|
||||
h.executor.RegisterTools(h.mcpServer)
|
||||
mcp.RegisterExecutionControlTools(h.mcpServer, h.externalMCPMgr)
|
||||
|
||||
// 重新注册漏洞记录工具(内置工具,必须注册)
|
||||
if h.vulnerabilityToolRegistrar != nil {
|
||||
|
||||
@@ -182,7 +182,7 @@ func summarizeAccessibleExecutionPage(executions []*mcp.ToolExecution, topN int)
|
||||
|
||||
func monitorStatusCountsAsFailed(status string) bool {
|
||||
switch strings.TrimSpace(strings.ToLower(status)) {
|
||||
case "failed", "cancelled", "hard_timeout", "orphaned":
|
||||
case "failed", "hard_timeout", "orphaned":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@@ -56,6 +56,7 @@ func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest, c *gin.Context
|
||||
var err error
|
||||
meta := audit.ConversationCreateMetaFromGin(c, source)
|
||||
meta.ProjectID = projectID
|
||||
meta.RoleName = req.Role
|
||||
if webshellID != "" {
|
||||
meta.Source = source + "_webshell"
|
||||
meta.WebShellConnectionID = webshellID
|
||||
@@ -80,6 +81,9 @@ func (h *AgentHandler) prepareMultiAgentSession(req *ChatRequest, c *gin.Context
|
||||
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)
|
||||
if err != nil {
|
||||
|
||||
@@ -5798,10 +5798,15 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"get": map[string]interface{}{
|
||||
"tags": []string{"对话附件"},
|
||||
"summary": "列出附件",
|
||||
"description": "获取对话附件文件列表,可按对话ID过滤。",
|
||||
"description": "获取对话文件列表,包含手动上传附件、工具输出和会话产物,可按会话、项目、来源、文件名搜索和分页过滤。",
|
||||
"operationId": "listChatUploads",
|
||||
"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"}},
|
||||
{"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{}{
|
||||
"200": map[string]interface{}{
|
||||
@@ -5823,11 +5828,18 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
|
||||
"modifiedUnix": map[string]interface{}{"type": "integer"},
|
||||
"date": map[string]interface{}{"type": "string"},
|
||||
"conversationId": map[string]interface{}{"type": "string"},
|
||||
"projectId": 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"}},
|
||||
"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{}{
|
||||
"get": map[string]interface{}{
|
||||
"tags": []string{"对话附件"},
|
||||
|
||||
@@ -61,7 +61,7 @@ var apiDocI18nSummaryToKey = map[string]string{
|
||||
"获取连接状态": "getWebshellConnectionState", "保存连接状态": "saveWebshellConnectionState",
|
||||
"获取AI对话历史": "getWebshellAIHistory", "列出AI对话": "listWebshellAIConversations",
|
||||
"执行WebShell命令": "webshellExec", "WebShell文件操作": "webshellFileOp",
|
||||
"列出附件": "listChatUploads", "上传附件": "uploadChatFile", "删除附件": "deleteChatUpload",
|
||||
"列出附件": "listChatUploads", "导出附件": "exportChatUploads", "上传附件": "uploadChatFile", "删除附件": "deleteChatUpload",
|
||||
"下载附件": "downloadChatUpload", "获取附件文本内容": "getChatUploadContent",
|
||||
"写入附件文本内容": "putChatUploadContent", "创建附件目录": "mkdirChatUpload", "重命名附件": "renameChatUpload",
|
||||
"企业微信回调验证": "wecomCallbackVerify", "企业微信消息回调": "wecomCallbackMessage",
|
||||
|
||||
@@ -721,15 +721,22 @@ func (h *WebShellHandler) Exec(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "url and command are required"})
|
||||
return
|
||||
}
|
||||
conn, allowed := h.authorizedWebshellConnection(c, req.ConnectionID, req.URL)
|
||||
if !allowed {
|
||||
// Pre-save connectivity tests send form credentials without connection_id.
|
||||
// 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": "无权访问该资源"})
|
||||
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)
|
||||
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"})
|
||||
return
|
||||
}
|
||||
conn, allowed := h.authorizedWebshellConnection(c, req.ConnectionID, req.URL)
|
||||
if !allowed {
|
||||
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
|
||||
}
|
||||
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": "无权访问该资源"})
|
||||
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)
|
||||
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
|
||||
@@ -40,15 +40,47 @@ func TestWebshellExecRequiresConnectionAccessWhenConnectionIDProvided(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebshellExecRejectsAdHocURLWithoutConnectionID(t *testing.T) {
|
||||
func TestWebshellExecAllowsAdHocURLWithoutConnectionID(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
_, user, _, _ := setupWebshellRBACTest(t)
|
||||
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{}{
|
||||
"url": "http://127.0.0.1/admin", "command": "id",
|
||||
"url": "http://127.0.0.1:1/admin", "command": "id",
|
||||
}, 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 {
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ import (
|
||||
const (
|
||||
defaultExecutionWaitTimeout = 60 * time.Second
|
||||
maxExecutionWaitTimeout = 10 * time.Minute
|
||||
defaultPartialPreviewBytes = 4096
|
||||
maxPartialPreviewBytes = 64 * 1024
|
||||
)
|
||||
|
||||
// RegisterExecutionControlTools exposes execution handle operations to Eino as
|
||||
@@ -32,7 +34,9 @@ func RegisterExecutionControlTools(server *Server, external *ExternalMCPManager)
|
||||
InputSchema: map[string]interface{}{
|
||||
"type": "object",
|
||||
"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"},
|
||||
},
|
||||
@@ -45,7 +49,7 @@ func RegisterExecutionControlTools(server *Server, external *ExternalMCPManager)
|
||||
if exec == 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{
|
||||
@@ -55,8 +59,10 @@ func RegisterExecutionControlTools(server *Server, external *ExternalMCPManager)
|
||||
InputSchema: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"execution_id": map[string]interface{}{"type": "string", "description": "工具执行 ID"},
|
||||
"timeout_seconds": map[string]interface{}{"type": "number", "description": "本次最多等待秒数,默认 60,最大 600"},
|
||||
"execution_id": map[string]interface{}{"type": "string", "description": "工具执行 ID"},
|
||||
"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"},
|
||||
},
|
||||
@@ -73,7 +79,7 @@ func RegisterExecutionControlTools(server *Server, external *ExternalMCPManager)
|
||||
if snap == nil || snap.Execution == nil {
|
||||
return textToolResult("未找到该 execution_id: "+id, true), nil
|
||||
}
|
||||
body := formatExecutionForModel(snap.Execution)
|
||||
body := formatExecutionForModel(snap.Execution, executionFormatOptionsFromArgs(args))
|
||||
if errors.Is(err, ErrExecutionWaitTimeout) {
|
||||
body += "\n\n本次等待已到达 timeout_seconds,上述 execution 仍未完成。可继续等待、取消,或采用其他步骤。"
|
||||
}
|
||||
@@ -144,7 +150,25 @@ func lookupToolExecution(server *Server, external *ExternalMCPManager, id string
|
||||
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 {
|
||||
return "execution: null"
|
||||
}
|
||||
@@ -167,6 +191,15 @@ func formatExecutionForModel(exec *ToolExecution) string {
|
||||
payload["result"] = ToolResultPlainText(exec.Result)
|
||||
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, "", " ")
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -348,6 +348,22 @@ func (s *ExecutionService) Get(executionID string) (*ExecutionSnapshot, error) {
|
||||
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 {
|
||||
id := strings.TrimSpace(executionID)
|
||||
if id == "" || s == nil {
|
||||
@@ -582,5 +598,28 @@ func cloneToolExecution(in *ToolExecution) *ToolExecution {
|
||||
t := *in.EndTime
|
||||
out.EndTime = &t
|
||||
}
|
||||
if in.PartialOutputUpdatedAt != nil {
|
||||
t := *in.PartialOutputUpdatedAt
|
||||
out.PartialOutputUpdatedAt = &t
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -745,7 +745,7 @@ func (m *ExternalMCPManager) CallTool(ctx context.Context, toolName string, args
|
||||
return result, callErr
|
||||
},
|
||||
OnDone: func(exec *ToolExecution) {
|
||||
failed := exec != nil && exec.Status != ToolExecutionStatusCompleted
|
||||
failed := exec != nil && exec.Status != ToolExecutionStatusCompleted && exec.Status != ToolExecutionStatusCancelled
|
||||
m.recordExternalMCPResult(mcpName, failed)
|
||||
m.updateStats(toolName, failed)
|
||||
},
|
||||
|
||||
+42
-3
@@ -59,6 +59,8 @@ type Server struct {
|
||||
spillRootDir string
|
||||
}
|
||||
|
||||
const defaultPartialOutputMaxBytes = 64 * 1024
|
||||
|
||||
// SetToolAuthorizer installs the common policy decision point for every
|
||||
// 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) {
|
||||
@@ -602,13 +604,13 @@ func (s *Server) handleCallTool(requestCtx context.Context, msg *Message) *Messa
|
||||
st, msg := executionStatusAndMessage(err)
|
||||
execution.Status = st
|
||||
execution.Error = msg
|
||||
failed = true
|
||||
failed = st != "cancelled"
|
||||
} else if result != nil && result.IsError {
|
||||
if cancelledWithUserNote {
|
||||
execution.Status = "cancelled"
|
||||
execution.Error = ""
|
||||
execution.Result = result
|
||||
failed = true
|
||||
failed = false
|
||||
} else {
|
||||
execution.Status = "failed"
|
||||
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)
|
||||
},
|
||||
OnDone: func(exec *ToolExecution) {
|
||||
failed := exec != nil && exec.Status != ToolExecutionStatusCompleted
|
||||
failed := exec != nil && exec.Status != ToolExecutionStatusCompleted && exec.Status != ToolExecutionStatusCancelled
|
||||
s.updateStats(toolName, failed)
|
||||
},
|
||||
})
|
||||
@@ -1123,6 +1125,25 @@ func (s *Server) FinishToolExecution(ctx context.Context, executionID, toolName
|
||||
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 结束后一致),
|
||||
// 用于 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 {
|
||||
@@ -1206,6 +1227,24 @@ func (s *Server) unregisterRunningCancel(id string) {
|
||||
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 {
|
||||
s.runningCancelsMu.Lock()
|
||||
defer s.runningCancelsMu.Unlock()
|
||||
|
||||
@@ -180,3 +180,41 @@ func TestWaitToolExecutionTimeoutIsObservationNotFailure(t *testing.T) {
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,6 +199,12 @@ type ToolExecution struct {
|
||||
StartTime time.Time `json:"startTime"`
|
||||
EndTime *time.Time `json:"endTime,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 string `json:"conversationId,omitempty"`
|
||||
OwnerUserID string `json:"-"`
|
||||
|
||||
@@ -12,6 +12,9 @@ import (
|
||||
// 与 CallTool 路径一致,使监控页能展示「执行中」状态。
|
||||
func newEinoExecuteMonitorCallbacks(ctx context.Context, ag *agent.Agent, recorder einomcp.ExecutionRecorder) (
|
||||
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),
|
||||
) {
|
||||
begin = func(toolCallID, command string) string {
|
||||
@@ -25,6 +28,24 @@ func newEinoExecuteMonitorCallbacks(ctx context.Context, ag *agent.Agent, record
|
||||
}
|
||||
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) {
|
||||
if ag == nil {
|
||||
return
|
||||
@@ -43,5 +64,5 @@ func newEinoExecuteMonitorCallbacks(ctx context.Context, ag *agent.Agent, record
|
||||
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)
|
||||
// toolTimeoutMinutes 与 agent.tool_timeout_minutes 对齐;>0 时对单次 execute 套用 context 超时(与 MCP 工具经 executeToolViaMCP 行为一致)。0 表示仅依赖上层 ctx(如整任务 10h 上限)。
|
||||
toolTimeoutMinutes int
|
||||
// toolWaitTimeoutSeconds 与 agent.tool_wait_timeout_seconds 对齐;>0 时本轮等待到期后返回 execution_id,shell 继续后台运行。
|
||||
toolWaitTimeoutSeconds int
|
||||
// shellNoOutputTimeoutSec:无任何输出时的空闲秒数;0=关闭。
|
||||
shellNoOutputTimeoutSec int
|
||||
// beginMonitor 在 execute 开始时写入 running 状态;finishMonitor 在流结束后更新为 completed/failed。
|
||||
beginMonitor func(toolCallID, command string) string
|
||||
finishMonitor func(executionID, toolCallID, command, stdout string, success bool, invokeErr error)
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
execCtx, timeoutCancel = context.WithTimeout(execCtx, time.Duration(w.toolTimeoutMinutes)*time.Minute)
|
||||
}
|
||||
if monitorExecID != "" && w.registerCancelMonitor != nil {
|
||||
w.registerCancelMonitor(monitorExecID, execCancel)
|
||||
}
|
||||
if execReg != nil && convID != "" {
|
||||
execReg.RegisterActiveEinoExecute(convID, execCancel)
|
||||
}
|
||||
@@ -116,6 +124,9 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
|
||||
if execCancel != nil {
|
||||
execCancel()
|
||||
}
|
||||
if monitorExecID != "" && w.unregisterCancelMonitor != nil {
|
||||
w.unregisterCancelMonitor(monitorExecID)
|
||||
}
|
||||
if einoExecuteRecvErrIsToolTimeout(err, execCtx) {
|
||||
hint := "\n\n" + einoExecuteTimeoutUserHint() + "\n"
|
||||
if w.finishMonitor != nil {
|
||||
@@ -146,7 +157,7 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
|
||||
|
||||
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
|
||||
closeInner := func() {
|
||||
innerCloseOnce.Do(func() { inner.Close() })
|
||||
@@ -164,6 +175,9 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
|
||||
if toolReg != nil && conversationID != "" && execID != "" {
|
||||
defer toolReg.UnregisterRunningTool(conversationID, execID)
|
||||
}
|
||||
if w.unregisterCancelMonitor != nil && execID != "" {
|
||||
defer w.unregisterCancelMonitor(execID)
|
||||
}
|
||||
|
||||
// ctx 取消时关闭内层流,避免 amass 等长时间无换行输出时 Recv 永久阻塞。
|
||||
stopWatch := make(chan struct{})
|
||||
@@ -181,11 +195,30 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
|
||||
var invokeErr error
|
||||
exitCode := 0
|
||||
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)
|
||||
if idleWatch != nil {
|
||||
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 {
|
||||
resp *filesystem.ExecuteResponse
|
||||
@@ -206,8 +239,11 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
|
||||
success = false
|
||||
invokeErr = fmt.Errorf("shell inactivity timeout (%ds)", idleWatch.Sec)
|
||||
msg := security.ShellNoOutputTimeoutMessage(idleWatch.Sec)
|
||||
_ = outW.Send(&filesystem.ExecuteResponse{Output: msg}, nil)
|
||||
_ = sendOut(&filesystem.ExecuteResponse{Output: msg}, nil)
|
||||
sb.WriteString(msg)
|
||||
if w.appendPartialMonitor != nil && execID != "" {
|
||||
w.appendPartialMonitor(execID, toolCallID, msg)
|
||||
}
|
||||
if w.outputChunk != nil && toolCallID != "" {
|
||||
w.outputChunk("execute", toolCallID, msg)
|
||||
}
|
||||
@@ -227,6 +263,14 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
|
||||
case <-idleCh:
|
||||
fireInactivityTimeout()
|
||||
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:
|
||||
rerr := msg.err
|
||||
resp := msg.resp
|
||||
@@ -244,7 +288,7 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
|
||||
invokeErr = context.Canceled
|
||||
break recvLoop
|
||||
}
|
||||
_ = outW.Send(nil, rerr)
|
||||
_ = sendOut(nil, rerr)
|
||||
break recvLoop
|
||||
}
|
||||
if resp != nil {
|
||||
@@ -263,11 +307,14 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
|
||||
}
|
||||
sb.WriteString(resp.Output)
|
||||
appended = resp.Output
|
||||
if w.appendPartialMonitor != nil && execID != "" {
|
||||
w.appendPartialMonitor(execID, toolCallID, appended)
|
||||
}
|
||||
}
|
||||
if w.outputChunk != nil && strings.TrimSpace(appended) != "" {
|
||||
w.outputChunk("execute", toolCallID, appended)
|
||||
}
|
||||
if outW.Send(resp, nil) {
|
||||
if sendOut(resp, nil) {
|
||||
success = false
|
||||
invokeErr = fmt.Errorf("execute stream closed by consumer")
|
||||
break recvLoop
|
||||
@@ -304,7 +351,10 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
|
||||
// ADK 从本 Pipe 拼出 tool 消息正文;仅 Notify 尾标不会进入模型上下文。超时句写入流,与 UI 一致。
|
||||
if invokeErr != nil && errors.Is(invokeErr, context.DeadlineExceeded) {
|
||||
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 != "" {
|
||||
w.outputChunk("execute", tid, hint)
|
||||
}
|
||||
@@ -313,9 +363,9 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
|
||||
// 中断时循环内已逐行写入 stdout;此处只追加 USER INTERRUPT NOTE,避免整段输出重复。
|
||||
if invokeErr != nil && errors.Is(invokeErr, context.Canceled) && abortNote != "" {
|
||||
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 != "" {
|
||||
_ = outW.Send(&filesystem.ExecuteResponse{Output: text + "\n"}, nil)
|
||||
_ = sendOut(&filesystem.ExecuteResponse{Output: text + "\n"}, nil)
|
||||
}
|
||||
}
|
||||
rawOutput := sb.String()
|
||||
@@ -323,7 +373,10 @@ func (w *einoStreamingShellWrap) ExecuteStreaming(ctx context.Context, input *fi
|
||||
if !success && hasExitCode && exitCode != 0 {
|
||||
statusLine := security.ExecuteFailureStatusLine(exitCode)
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}(sr, userCmd, execCancel, timeoutCancel, execCtx, convID, execReg, toolRunReg, monitorExecID, tid, w.shellNoOutputTimeoutSec, w.toolWaitTimeoutSeconds)
|
||||
|
||||
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 {
|
||||
called bool
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ func RunEinoSingleChatModelAgent(
|
||||
}
|
||||
|
||||
toolInvokeNotify := einomcp.NewToolInvokeNotifyHolder()
|
||||
einoExecBegin, einoExecFinish := newEinoExecuteMonitorCallbacks(ctx, ag, recorder)
|
||||
einoExecBegin, einoExecAppendPartial, einoExecRegisterCancel, einoExecUnregisterCancel, einoExecFinish := newEinoExecuteMonitorCallbacks(ctx, ag, recorder)
|
||||
mainDefs := ag.ToolsForRole(roleTools)
|
||||
mainTools, err := einomcp.ToolsFromDefinitions(ag, holder, mainDefs, recorder, nil, toolInvokeNotify, einoSingleAgentName)
|
||||
if err != nil {
|
||||
@@ -139,7 +139,7 @@ func RunEinoSingleChatModelAgent(
|
||||
}
|
||||
if einoSkillMW != 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 {
|
||||
return nil, fmt.Errorf("eino single filesystem 中间件: %w", fsErr)
|
||||
}
|
||||
|
||||
@@ -107,8 +107,12 @@ func subAgentFilesystemMiddleware(
|
||||
invokeNotify *einomcp.ToolInvokeNotifyHolder,
|
||||
einoAgentName 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),
|
||||
toolTimeoutMinutes int,
|
||||
toolWaitTimeoutSeconds int,
|
||||
shellNoOutputTimeoutSec int,
|
||||
outputChunk func(toolName, toolCallID, chunk string),
|
||||
) (adk.ChatModelAgentMiddleware, error) {
|
||||
@@ -123,8 +127,12 @@ func subAgentFilesystemMiddleware(
|
||||
einoAgentName: strings.TrimSpace(einoAgentName),
|
||||
outputChunk: outputChunk,
|
||||
beginMonitor: beginMonitor,
|
||||
appendPartialMonitor: appendPartialMonitor,
|
||||
registerCancelMonitor: registerCancelMonitor,
|
||||
unregisterCancelMonitor: unregisterCancelMonitor,
|
||||
finishMonitor: finishMonitor,
|
||||
toolTimeoutMinutes: toolTimeoutMinutes,
|
||||
toolWaitTimeoutSeconds: toolWaitTimeoutSeconds,
|
||||
shellNoOutputTimeoutSec: shellNoOutputTimeoutSec,
|
||||
},
|
||||
})
|
||||
@@ -138,6 +146,13 @@ func agentToolTimeoutMinutes(cfg *config.Config) int {
|
||||
return cfg.Agent.ToolTimeoutMinutes
|
||||
}
|
||||
|
||||
func agentToolWaitTimeoutSeconds(cfg *config.Config) int {
|
||||
if cfg == nil {
|
||||
return 0
|
||||
}
|
||||
return cfg.Agent.ToolWaitTimeoutSeconds
|
||||
}
|
||||
|
||||
// agentShellNoOutputTimeoutSeconds:0=默认 300s(5 分钟);-1=关闭;>0=自定义秒数。
|
||||
func agentShellNoOutputTimeoutSeconds(cfg *config.Config) int {
|
||||
if cfg == nil {
|
||||
|
||||
@@ -135,7 +135,7 @@ func RunDeepAgent(
|
||||
mcpIDs = append(mcpIDs, id)
|
||||
mcpIDsMu.Unlock()
|
||||
}
|
||||
einoExecBegin, einoExecFinish := newEinoExecuteMonitorCallbacks(ctx, ag, recorder)
|
||||
einoExecBegin, einoExecAppendPartial, einoExecRegisterCancel, einoExecUnregisterCancel, einoExecFinish := newEinoExecuteMonitorCallbacks(ctx, ag, recorder)
|
||||
|
||||
// 与单代理流式一致:在 response_start / response_delta 的 data 中带当前 mcpExecutionIds,供主聊天绑定复制与展示。
|
||||
snapshotMCPIDs := func() []string {
|
||||
@@ -240,7 +240,7 @@ func RunDeepAgent(
|
||||
}
|
||||
if einoSkillMW != 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 {
|
||||
return nil, fmt.Errorf("子代理 %q filesystem 中间件: %w", id, fsErr)
|
||||
}
|
||||
@@ -387,8 +387,12 @@ func RunDeepAgent(
|
||||
einoAgentName: orchestratorName,
|
||||
outputChunk: nil,
|
||||
beginMonitor: einoExecBegin,
|
||||
appendPartialMonitor: einoExecAppendPartial,
|
||||
registerCancelMonitor: einoExecRegisterCancel,
|
||||
unregisterCancelMonitor: einoExecUnregisterCancel,
|
||||
finishMonitor: einoExecFinish,
|
||||
toolTimeoutMinutes: agentToolTimeoutMinutes(appCfg),
|
||||
toolWaitTimeoutSeconds: agentToolWaitTimeoutSeconds(appCfg),
|
||||
shellNoOutputTimeoutSec: agentShellNoOutputTimeoutSeconds(appCfg),
|
||||
}
|
||||
}
|
||||
@@ -486,7 +490,7 @@ func RunDeepAgent(
|
||||
// 构建 filesystem 中间件(与 Deep sub-agent 一致)
|
||||
var peFsMw adk.ChatModelAgentMiddleware
|
||||
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 {
|
||||
return nil, fmt.Errorf("plan_execute filesystem 中间件: %w", err)
|
||||
}
|
||||
|
||||
@@ -74,6 +74,21 @@ func (e *Executor) SetToolOutputSpillRoot(rootDir string) {
|
||||
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 {
|
||||
root := ""
|
||||
if e != nil {
|
||||
@@ -184,8 +199,9 @@ func (e *Executor) ExecuteTool(ctx context.Context, toolName string, args map[st
|
||||
var output string
|
||||
var err error
|
||||
spill := e.spillOptsFromContext(ctx)
|
||||
// 如果上层提供了 stdout/stderr 增量回调,则边执行边读取并回调。
|
||||
if cb, ok := ctx.Value(ToolOutputCallbackCtxKey).(ToolOutputCallback); ok && cb != nil {
|
||||
// 如果上层提供了 stdout/stderr 增量回调,或当前处于 MCP execution 中,则边执行边读取并回调。
|
||||
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)
|
||||
if err != nil && shouldRetryWithPTY(output) {
|
||||
e.logger.Info("检测到工具需要 TTY,使用 PTY 重试",
|
||||
@@ -948,8 +964,9 @@ func (e *Executor) executeSystemCommand(ctx context.Context, args map[string]int
|
||||
var output string
|
||||
var err error
|
||||
spill := e.spillOptsFromContext(ctx)
|
||||
// 若上层提供工具输出增量回调,则边执行边流式读取。
|
||||
if cb, ok := ctx.Value(ToolOutputCallbackCtxKey).(ToolOutputCallback); ok && cb != nil {
|
||||
// 若上层提供工具输出增量回调,或当前处于 MCP execution 中,则边执行边流式读取。
|
||||
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)
|
||||
if err != nil && shouldRetryWithPTY(output) {
|
||||
e.logger.Info("检测到系统命令需要 TTY,使用 PTY 重试")
|
||||
|
||||
@@ -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) {
|
||||
executor, _ := setupTestExecutor(t)
|
||||
res, err := executor.executeSystemCommand(context.Background(), map[string]interface{}{
|
||||
|
||||
@@ -5468,7 +5468,7 @@ html[data-theme="dark"] .login-card .login-submit:disabled {
|
||||
|
||||
#mcp-detail-modal .detail-code-card {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
overflow: auto;
|
||||
background: #f8fafc;
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
border-radius: 8px;
|
||||
@@ -5530,6 +5530,7 @@ html[data-theme="dark"] .login-card .login-submit:disabled {
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
overflow: auto;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
@@ -10766,6 +10767,11 @@ html[data-theme="dark"] .robot-binding-service-hint-icon {
|
||||
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 {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
@@ -29950,6 +29956,20 @@ html[data-theme="dark"] #info-collect-syntax-panel.is-generated-target {
|
||||
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 {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
@@ -30098,6 +30118,16 @@ html[data-theme="dark"] #info-collect-syntax-panel.is-generated-target {
|
||||
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 {
|
||||
background: rgba(0, 102, 255, 0.06);
|
||||
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);
|
||||
}
|
||||
|
||||
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"] pre,
|
||||
html[data-theme="dark"] .code-block,
|
||||
|
||||
@@ -210,6 +210,7 @@
|
||||
"toolsCountLabel_other": "{{count}} tools",
|
||||
"failedNCalls_one": "{{count}} failed",
|
||||
"failedNCalls_other": "{{count}} failed",
|
||||
"noCompletedYet": "No completed outcomes yet",
|
||||
"noCallYet": "No calls yet",
|
||||
"allClear": "No new risks",
|
||||
"allIdle": "System idle",
|
||||
@@ -2014,6 +2015,7 @@
|
||||
"webshellExec": "Execute WebShell command",
|
||||
"webshellFileOp": "WebShell file operation",
|
||||
"listChatUploads": "List uploads",
|
||||
"exportChatUploads": "Export uploads",
|
||||
"uploadChatFile": "Upload file",
|
||||
"deleteChatUpload": "Delete upload",
|
||||
"downloadChatUpload": "Download upload",
|
||||
@@ -2170,6 +2172,7 @@
|
||||
"lastCall": "Last call",
|
||||
"lastRefreshTime": "Last refresh",
|
||||
"noCallsYet": "No calls yet",
|
||||
"noCompletedYet": "No completed outcomes yet",
|
||||
"unknownTool": "Unknown tool",
|
||||
"successFailedRate": "Success {{success}} / Failed {{failed}} · {{rate}}% success rate",
|
||||
"topToolsTitle": "Top {{n}} tools by calls",
|
||||
@@ -2192,6 +2195,7 @@
|
||||
"clearToolFilter": "Clear tool filter",
|
||||
"successCount": "Success {{n}}",
|
||||
"failedCount": "Failed {{n}}",
|
||||
"neutralCount": "Stopped {{n}}",
|
||||
"rateHealthy": "Running smoothly",
|
||||
"rateWarning": "Some failures detected",
|
||||
"rateCritical": "High failure rate",
|
||||
@@ -2345,20 +2349,40 @@
|
||||
"chatFilesPage": {
|
||||
"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.",
|
||||
"export": "Export Current Results",
|
||||
"exportStarted": "Started exporting current files",
|
||||
"exportEmpty": "No files to export",
|
||||
"upload": "Upload",
|
||||
"conversationFilter": "Conversation ID",
|
||||
"conversationPlaceholder": "Leave empty for all",
|
||||
"projectFilter": "Project ID",
|
||||
"projectPlaceholder": "Leave empty for all",
|
||||
"searchName": "File name",
|
||||
"searchNamePlaceholder": "Filter by file name",
|
||||
"sourceFilter": "Source",
|
||||
"sourceAll": "All",
|
||||
"sourceUpload": "Chat uploads",
|
||||
"sourceReduction": "Tool outputs",
|
||||
"sourceConversationArtifact": "Conversation artifacts",
|
||||
"groupBy": "Group by",
|
||||
"groupNone": "None (flat list)",
|
||||
"groupByDate": "By date",
|
||||
"groupByConversation": "By conversation",
|
||||
"groupByProject": "By project",
|
||||
"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",
|
||||
"enterFolderTitle": "Open folder",
|
||||
"copyFolderPathTitle": "Copy relative path under chat_uploads/…",
|
||||
"copyFolderPathTitle": "Copy folder path",
|
||||
"folderPathCopied": "Folder path copied — paste into chat if needed",
|
||||
"folderEmpty": "This folder is empty",
|
||||
"confirmDeleteFolder": "Delete this folder and everything inside it? This cannot be undone.",
|
||||
|
||||
@@ -207,6 +207,7 @@
|
||||
"highCountLabel": "高危 {{count}}",
|
||||
"toolsCountLabel": "{{count}} 个工具",
|
||||
"failedNCalls": "{{count}} 次失败",
|
||||
"noCompletedYet": "暂无有效完成",
|
||||
"noCallYet": "暂无调用",
|
||||
"allClear": "暂无新增风险",
|
||||
"allIdle": "系统空闲",
|
||||
@@ -2002,6 +2003,7 @@
|
||||
"webshellExec": "执行WebShell命令",
|
||||
"webshellFileOp": "WebShell文件操作",
|
||||
"listChatUploads": "列出附件",
|
||||
"exportChatUploads": "导出附件",
|
||||
"uploadChatFile": "上传附件",
|
||||
"deleteChatUpload": "删除附件",
|
||||
"downloadChatUpload": "下载附件",
|
||||
@@ -2158,6 +2160,7 @@
|
||||
"lastCall": "最近一次调用",
|
||||
"lastRefreshTime": "最后刷新时间",
|
||||
"noCallsYet": "暂无调用",
|
||||
"noCompletedYet": "暂无有效完成",
|
||||
"unknownTool": "未知工具",
|
||||
"successFailedRate": "成功 {{success}} / 失败 {{failed}} · 成功率 {{rate}}%",
|
||||
"topToolsTitle": "工具调用 Top {{n}}",
|
||||
@@ -2180,6 +2183,7 @@
|
||||
"clearToolFilter": "清除工具筛选",
|
||||
"successCount": "成功 {{n}}",
|
||||
"failedCount": "失败 {{n}}",
|
||||
"neutralCount": "终止 {{n}}",
|
||||
"rateHealthy": "运行平稳",
|
||||
"rateWarning": "存在失败调用",
|
||||
"rateCritical": "失败率偏高",
|
||||
@@ -2333,20 +2337,40 @@
|
||||
"chatFilesPage": {
|
||||
"title": "文件管理",
|
||||
"intro": "管理在对话中上传的文件。可将文件拖拽到下方列表区域,或点击「上传文件」选择文件(支持多选)。需要让 AI 引用某文件时,在列表中点击「复制路径」,到对话里粘贴即可(路径为服务器上的绝对路径,与对话附件保存位置一致)。",
|
||||
"export": "导出当前结果",
|
||||
"exportStarted": "已开始导出当前文件",
|
||||
"exportEmpty": "没有可导出的文件",
|
||||
"upload": "上传文件",
|
||||
"conversationFilter": "会话 ID",
|
||||
"conversationPlaceholder": "留空表示全部",
|
||||
"projectFilter": "项目 ID",
|
||||
"projectPlaceholder": "留空表示全部",
|
||||
"searchName": "文件名",
|
||||
"searchNamePlaceholder": "筛选文件名",
|
||||
"sourceFilter": "来源",
|
||||
"sourceAll": "全部",
|
||||
"sourceUpload": "对话附件",
|
||||
"sourceReduction": "工具输出",
|
||||
"sourceConversationArtifact": "会话产物",
|
||||
"groupBy": "分组方式",
|
||||
"groupNone": "不分组(平铺)",
|
||||
"groupByDate": "按日期",
|
||||
"groupByConversation": "按会话",
|
||||
"groupByProject": "按项目",
|
||||
"groupByFolder": "按文件夹(路径浏览)",
|
||||
"browseRoot": "chat_uploads",
|
||||
"projectUnbound": "未绑定项目",
|
||||
"paginationInfo": "显示 {{start}}-{{end}} / {{total}} 个文件",
|
||||
"paginationPage": "第 {{page}} / {{totalPages}} 页",
|
||||
"pageSize": "每页",
|
||||
"prevPage": "上一页",
|
||||
"nextPage": "下一页",
|
||||
"browseRoot": "文件",
|
||||
"treeUploadsRoot": "对话附件",
|
||||
"treeReductionRoot": "工具输出",
|
||||
"treeArtifactsRoot": "会话产物",
|
||||
"browseUp": "上级",
|
||||
"enterFolderTitle": "进入此文件夹",
|
||||
"copyFolderPathTitle": "复制该目录的相对路径(chat_uploads/…)",
|
||||
"copyFolderPathTitle": "复制目录路径",
|
||||
"folderPathCopied": "目录路径已复制,可粘贴到对话中",
|
||||
"folderEmpty": "此文件夹为空",
|
||||
"confirmDeleteFolder": "确定删除该文件夹及其中的全部文件?此操作不可恢复。",
|
||||
|
||||
+387
-54
@@ -6,18 +6,26 @@ let chatFilesFoldersCache = [];
|
||||
let chatFilesDisplayed = [];
|
||||
let chatFilesEditRelativePath = '';
|
||||
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_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 = [];
|
||||
/** 非空时,下一次上传文件落到此相对路径(chat_uploads 下目录),如 2026-03-21/uuid/sub */
|
||||
let chatFilesPendingUploadDir = '';
|
||||
/** 文件管理页面向服务器上传进行中,避免重复选择并禁用顶栏按钮 */
|
||||
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 = {};
|
||||
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>';
|
||||
@@ -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) {
|
||||
let node = root;
|
||||
let i;
|
||||
@@ -217,6 +236,7 @@ function chatFilesNormalizeBrowsePathForTree(root) {
|
||||
|
||||
function initChatFilesPage() {
|
||||
chatFilesLoadBrowsePathFromStorage();
|
||||
chatFilesLoadPageSizeFromStorage();
|
||||
try {
|
||||
localStorage.removeItem('csai_chat_files_synthetic_dirs');
|
||||
} catch (e) {
|
||||
@@ -227,7 +247,7 @@ function initChatFilesPage() {
|
||||
if (sel) {
|
||||
try {
|
||||
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;
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -324,6 +344,8 @@ function ensureChatFilesDocClickClose() {
|
||||
async function loadChatFilesPage() {
|
||||
const wrap = document.getElementById('chat-files-list-wrap');
|
||||
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--tree');
|
||||
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 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) {
|
||||
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 {
|
||||
const res = await apiFetch(url);
|
||||
@@ -347,6 +390,16 @@ async function loadChatFilesPage() {
|
||||
const data = await res.json();
|
||||
chatFilesCache = Array.isArray(data.files) ? data.files : [];
|
||||
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();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -354,24 +407,141 @@ async function loadChatFilesPage() {
|
||||
wrap.classList.remove('chat-files-table-wrap--tree');
|
||||
const msg = (typeof window.t === 'function') ? window.t('chatFilesPage.errorLoad') : '加载失败';
|
||||
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) {
|
||||
const el = document.getElementById('chat-files-filter-name');
|
||||
const q = el ? el.value.trim().toLowerCase() : '';
|
||||
if (!q) return files;
|
||||
return files.filter(function (f) {
|
||||
const name = (f.name || '').toLowerCase();
|
||||
const sub = (f.subPath || '').toLowerCase();
|
||||
return name.includes(q) || sub.includes(q);
|
||||
});
|
||||
return Array.isArray(files) ? files : [];
|
||||
}
|
||||
|
||||
function chatFilesResetToFirstPageAndLoad() {
|
||||
chatFilesPage = 1;
|
||||
loadChatFilesPage();
|
||||
}
|
||||
|
||||
/** 仅前端按文件名筛选,不重新请求 */
|
||||
function chatFilesFilterNameOnInput() {
|
||||
if (!chatFilesCache.length && !chatFilesFoldersCache.length && chatFilesGetGroupByMode() !== 'folder') return;
|
||||
renderChatFilesTable();
|
||||
if (chatFilesSearchDebounceTimer) clearTimeout(chatFilesSearchDebounceTimer);
|
||||
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) {
|
||||
@@ -477,7 +647,7 @@ function chatFilesAlertMessage(raw) {
|
||||
function chatFilesGetGroupByMode() {
|
||||
const sel = document.getElementById('chat-files-group-by');
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -490,7 +660,7 @@ function chatFilesGroupByChange() {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
renderChatFilesTable();
|
||||
chatFilesResetToFirstPageAndLoad();
|
||||
}
|
||||
|
||||
function chatFilesCompareDateKeysDesc(a, b) {
|
||||
@@ -507,7 +677,7 @@ function chatFilesTreeMakeNode() {
|
||||
}
|
||||
|
||||
function chatFilesTreeInsertFile(root, f, idx) {
|
||||
const rp = String(f.relativePath || '').replace(/\\/g, '/').replace(/^\/+/, '');
|
||||
const rp = chatFilesTreePathForFile(f);
|
||||
if (!rp) return;
|
||||
const parts = rp.split('/').filter(function (p) {
|
||||
return p.length > 0;
|
||||
@@ -530,6 +700,21 @@ function chatFilesBuildTree(files) {
|
||||
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)并入树,便于展示空文件夹 */
|
||||
function chatFilesTreeInsertFolderPath(root, relSlash) {
|
||||
const rp = String(relSlash || '').replace(/\\/g, '/').replace(/^\/+/, '');
|
||||
@@ -549,18 +734,94 @@ function chatFilesTreeInsertFolderPath(root, relSlash) {
|
||||
|
||||
function chatFilesMergeFoldersIntoTree(root, folderPaths) {
|
||||
if (!Array.isArray(folderPaths)) return;
|
||||
if (!chatFilesShouldMergeUploadFolders()) return;
|
||||
let i;
|
||||
for (i = 0; i < folderPaths.length; i++) {
|
||||
chatFilesTreeInsertFolderPath(root, folderPaths[i]);
|
||||
chatFilesTreeInsertFolderPath(root, CHAT_FILES_TREE_UPLOAD_ROOT + '/' + folderPaths[i]);
|
||||
}
|
||||
}
|
||||
|
||||
function chatFilesTreeRootMerged() {
|
||||
const root = chatFilesBuildTree(chatFilesDisplayed);
|
||||
chatFilesMergeFoldersIntoTree(root, chatFilesFoldersCache);
|
||||
chatFilesEnsureSourceRoots(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) {
|
||||
let m = 0;
|
||||
let i;
|
||||
@@ -587,7 +848,14 @@ function chatFilesTreeSortDirKeys(node, keys) {
|
||||
function chatFilesBuildGroups(files, mode) {
|
||||
const map = new Map();
|
||||
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)) {
|
||||
map.set(key, { key: key, items: [] });
|
||||
}
|
||||
@@ -623,12 +891,20 @@ function chatFilesBuildGroups(files, mode) {
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** 分组标题:会话 ID 过长时缩短展示,完整值放在 title */
|
||||
function chatFilesGroupHeadingConversation(key) {
|
||||
/** 分组标题:长 ID 缩短展示,完整值放在 title */
|
||||
function chatFilesGroupHeadingID(key, emptyLabel) {
|
||||
const c = key == null ? '' : String(key);
|
||||
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 (c === '_manual') {
|
||||
return { text: window.t('chatFilesPage.convManual'), title: '_manual' };
|
||||
@@ -637,10 +913,12 @@ function chatFilesGroupHeadingConversation(key) {
|
||||
return { text: window.t('chatFilesPage.convNew'), title: '_new' };
|
||||
}
|
||||
}
|
||||
if (c.length > 36) {
|
||||
return { text: c.slice(0, 8) + '…' + c.slice(-6), title: c };
|
||||
}
|
||||
return { text: c, title: c };
|
||||
return chatFilesGroupHeadingID(key);
|
||||
}
|
||||
|
||||
function chatFilesGroupHeadingProject(key) {
|
||||
const empty = (typeof window.t === 'function') ? window.t('chatFilesPage.projectUnbound') : '未绑定项目';
|
||||
return chatFilesGroupHeadingID(key, empty);
|
||||
}
|
||||
|
||||
function renderChatFilesTable() {
|
||||
@@ -658,6 +936,7 @@ function renderChatFilesTable() {
|
||||
if (typeof window.applyTranslations === 'function') {
|
||||
window.applyTranslations(wrap);
|
||||
}
|
||||
renderChatFilesPagination();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -683,6 +962,8 @@ function renderChatFilesTable() {
|
||||
const rp = f.relativePath || '';
|
||||
const pathForTitle = (f.absolutePath && String(f.absolutePath).trim()) ? String(f.absolutePath).trim() : rp;
|
||||
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 convEsc = escapeHtml(conv);
|
||||
const dt = f.modifiedUnix ? new Date(f.modifiedUnix * 1000).toLocaleString() : '—';
|
||||
@@ -700,13 +981,17 @@ function renderChatFilesTable() {
|
||||
if (canOpenChat) {
|
||||
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>`);
|
||||
} else {
|
||||
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>`);
|
||||
menuParts.push(`<button type="button" class="chat-files-dropdown-item is-danger" onclick="chatFilesCloseAllMenus(); deleteChatFileIdx(${idx});">${tDelete}</button>`);
|
||||
if (!isInternal) {
|
||||
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 subRaw = (f.subPath && String(f.subPath).trim()) ? String(f.subPath).trim() : '';
|
||||
@@ -728,7 +1013,7 @@ function renderChatFilesTable() {
|
||||
<td>${escapeHtml(f.date || '—')}</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-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>${escapeHtml(dt)}</td>
|
||||
<td class="chat-files-actions">
|
||||
@@ -774,11 +1059,11 @@ function renderChatFilesTable() {
|
||||
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 tMkdir = escapeHtml((typeof window.t === 'function') ? window.t('chatFilesPage.newFolderButton') : '新建文件夹');
|
||||
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') : '进入');
|
||||
|
||||
let breadcrumbHtml = '<nav class="chat-files-breadcrumb" aria-label="breadcrumb">';
|
||||
@@ -789,16 +1074,20 @@ function renderChatFilesTable() {
|
||||
const isLast = bi === chatFilesBrowsePath.length - 1;
|
||||
breadcrumbHtml += '<span class="chat-files-breadcrumb-sep">/</span>';
|
||||
if (isLast) {
|
||||
breadcrumbHtml += '<span class="chat-files-breadcrumb-current">' + escapeHtml(seg) + '</span>';
|
||||
breadcrumbHtml += '<span class="chat-files-breadcrumb-current">' + escapeHtml(chatFilesTreeDisplayName(seg)) + '</span>';
|
||||
} 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>';
|
||||
|
||||
const canMutateCurrentPath = chatFilesBrowseCanMutateCurrentPath();
|
||||
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 +
|
||||
'<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>';
|
||||
|
||||
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) {
|
||||
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 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)">
|
||||
<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 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()">
|
||||
<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 btn-danger" title="${tDeleteFolder}" data-chat-folder-name="${nameAttr}" onclick="chatFilesDeleteFolderFromBtn(event, this)">${svgTrash}</button>
|
||||
${deleteBtn}
|
||||
</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
@@ -829,6 +1127,8 @@ function renderChatFilesTable() {
|
||||
function rowHtmlTreeFile(f, idx) {
|
||||
const pathForTitle = (f.absolutePath && String(f.absolutePath).trim()) ? String(f.absolutePath).trim() : (f.relativePath || '');
|
||||
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 conv = f.conversationId || '';
|
||||
const canOpenChat = conv && conv !== '_manual' && conv !== '_new';
|
||||
@@ -845,18 +1145,22 @@ function renderChatFilesTable() {
|
||||
if (canOpenChat) {
|
||||
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>`);
|
||||
} else {
|
||||
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>`);
|
||||
menuParts.push(`<button type="button" class="chat-files-dropdown-item is-danger" onclick="chatFilesCloseAllMenus(); deleteChatFileIdx(${idx});">${tDelete}</button>`);
|
||||
if (!isInternal) {
|
||||
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('');
|
||||
|
||||
return `<tr class="chat-files-tr-file">
|
||||
<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>${formatChatFileBytes(f.size || 0)}</td>
|
||||
<td>${escapeHtml(dt)}</td>
|
||||
@@ -899,6 +1203,10 @@ function renderChatFilesTable() {
|
||||
let summaryTitleAttr = '';
|
||||
if (groupMode === 'date') {
|
||||
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 {
|
||||
const h = chatFilesGroupHeadingConversation(g.key);
|
||||
summaryMain = escapeHtml(h.text);
|
||||
@@ -926,9 +1234,13 @@ function renderChatFilesTable() {
|
||||
wrap.innerHTML = innerHtml;
|
||||
wrap.classList.toggle('chat-files-table-wrap--grouped', groupMode !== 'none' && groupMode !== 'folder');
|
||||
wrap.classList.toggle('chat-files-table-wrap--tree', groupMode === 'folder');
|
||||
renderChatFilesPagination();
|
||||
}
|
||||
|
||||
window.chatFilesGroupByChange = chatFilesGroupByChange;
|
||||
window.changeChatFilesPage = changeChatFilesPage;
|
||||
window.changeChatFilesPageSize = changeChatFilesPageSize;
|
||||
window.chatFilesResetToFirstPageAndLoad = chatFilesResetToFirstPageAndLoad;
|
||||
|
||||
function chatFilesNavigateInto(name) {
|
||||
const root = chatFilesTreeRootMerged();
|
||||
@@ -989,7 +1301,8 @@ function chatFilesCopyFolderPathFromBtn(ev, btn) {
|
||||
|
||||
async function deleteChatFolderFromBrowse(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') : '确定删除该文件夹及其中的全部文件?';
|
||||
if (!confirm(q)) return;
|
||||
try {
|
||||
@@ -1038,8 +1351,7 @@ function chatFilesDeleteFolderFromBtn(ev, btn) {
|
||||
|
||||
async function copyChatFolderPathFromBrowse(folderName) {
|
||||
const segs = chatFilesBrowsePath.concat([folderName]);
|
||||
const rel = segs.join('/');
|
||||
const text = rel ? ('chat_uploads/' + rel.replace(/^\/+/, '')) : 'chat_uploads';
|
||||
const text = chatFilesClipboardFolderPath(segs);
|
||||
const ok = await chatFilesCopyText(text);
|
||||
if (ok) {
|
||||
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.chatFilesNavigateBreadcrumb = chatFilesNavigateBreadcrumb;
|
||||
window.chatFilesNavigateUp = chatFilesNavigateUp;
|
||||
@@ -1060,6 +1388,7 @@ window.chatFilesCopyFolderPathFromBtn = chatFilesCopyFolderPathFromBtn;
|
||||
window.chatFilesDeleteFolderFromBtn = chatFilesDeleteFolderFromBtn;
|
||||
window.chatFilesOpenUploadPicker = chatFilesOpenUploadPicker;
|
||||
window.chatFilesUploadToFolderClick = chatFilesUploadToFolderClick;
|
||||
window.exportChatFiles = exportChatFiles;
|
||||
window.openChatFilesMkdirModal = openChatFilesMkdirModal;
|
||||
window.closeChatFilesMkdirModal = closeChatFilesMkdirModal;
|
||||
window.submitChatFilesMkdir = submitChatFilesMkdir;
|
||||
@@ -1254,11 +1583,12 @@ async function submitChatFilesRename() {
|
||||
|
||||
function openChatFilesMkdirModal() {
|
||||
if (chatFilesGetGroupByMode() !== 'folder') return;
|
||||
if (!chatFilesBrowseCanMutateCurrentPath()) return;
|
||||
const hint = document.getElementById('chat-files-mkdir-parent-hint');
|
||||
const input = document.getElementById('chat-files-mkdir-input');
|
||||
const modal = document.getElementById('chat-files-mkdir-modal');
|
||||
const p = chatFilesBrowsePath.join('/');
|
||||
if (hint) hint.textContent = p ? ('chat_uploads/' + p) : 'chat_uploads';
|
||||
const p = chatFilesBrowsePath.map(chatFilesTreeDisplayName).join('/');
|
||||
if (hint) hint.textContent = p || chatFilesTreeDisplayName(CHAT_FILES_TREE_UPLOAD_ROOT);
|
||||
if (input) input.value = '';
|
||||
if (modal) openAppModal(modal);
|
||||
if (modal && typeof window.applyTranslations === 'function') {
|
||||
@@ -1289,7 +1619,7 @@ async function submitChatFilesMkdir() {
|
||||
alert(msg);
|
||||
return;
|
||||
}
|
||||
const parent = chatFilesBrowsePath.join('/');
|
||||
const parent = chatFilesUploadRelativeDirFromBrowsePath(chatFilesBrowsePath);
|
||||
try {
|
||||
const res = await apiFetch('/api/chat-uploads/mkdir', {
|
||||
method: 'POST',
|
||||
@@ -1355,7 +1685,8 @@ function chatFilesSetUploadBusy(busy) {
|
||||
function chatFilesOpenUploadPicker() {
|
||||
if (chatFilesXHRUploadBusy) return;
|
||||
if (chatFilesGetGroupByMode() === 'folder') {
|
||||
chatFilesPendingUploadDir = chatFilesBrowsePath.join('/');
|
||||
if (!chatFilesBrowseCanMutateCurrentPath()) return;
|
||||
chatFilesPendingUploadDir = chatFilesUploadRelativeDirFromBrowsePath(chatFilesBrowsePath);
|
||||
} else {
|
||||
chatFilesPendingUploadDir = '';
|
||||
}
|
||||
@@ -1370,6 +1701,7 @@ function chatFilesUploadToFolderClick(ev, btn) {
|
||||
if (!raw) return;
|
||||
try {
|
||||
chatFilesPendingUploadDir = decodeURIComponent(raw);
|
||||
chatFilesPendingUploadDir = chatFilesUploadRelativeDirFromBrowsePath(chatFilesPendingUploadDir.split('/').filter(Boolean));
|
||||
} catch (e) {
|
||||
chatFilesPendingUploadDir = '';
|
||||
return;
|
||||
@@ -1385,8 +1717,9 @@ function chatFilesResolveUploadTarget() {
|
||||
return { relativeDir: pendingDir };
|
||||
}
|
||||
if (chatFilesGetGroupByMode() === 'folder') {
|
||||
const dir = chatFilesBrowsePath.join('/');
|
||||
return dir ? { relativeDir: dir } : {};
|
||||
if (!chatFilesBrowseCanMutateCurrentPath()) return {};
|
||||
const relDir = chatFilesUploadRelativeDirFromBrowsePath(chatFilesBrowsePath);
|
||||
return relDir ? { relativeDir: relDir } : {};
|
||||
}
|
||||
const conv = document.getElementById('chat-files-filter-conv');
|
||||
if (conv && conv.value.trim()) {
|
||||
|
||||
+52
-44
@@ -2982,8 +2982,9 @@ function prefetchProcessDetailsSummaryHint(messageId, messageElement) {
|
||||
if (summaryMcpIds.length > 0) {
|
||||
setPendingMcpExecutionIds(messageElement, summaryMcpIds);
|
||||
}
|
||||
if (toolCount > 0) {
|
||||
setMcpExecutionSummaryCount(messageElement, toolCount);
|
||||
const buttonToolCount = summaryTools.length > 0 ? summaryTools.length : (toolCount || summaryMcpIds.length);
|
||||
if (buttonToolCount > 0) {
|
||||
setMcpExecutionSummaryCount(messageElement, buttonToolCount);
|
||||
}
|
||||
const timeline = detailsContainer.querySelector('.progress-timeline');
|
||||
if (!timeline || detailsContainer.dataset.loaded === '1') return;
|
||||
@@ -3082,8 +3083,6 @@ function getPendingToolExecutionSummaryCount(messageElement) {
|
||||
}
|
||||
|
||||
function getMcpExecutionCount(messageElement) {
|
||||
const pending = getPendingMcpExecutionCount(messageElement);
|
||||
if (pending > 0) return pending;
|
||||
const pendingSummaries = getPendingToolExecutionSummaryCount(messageElement);
|
||||
if (pendingSummaries > 0) return pendingSummaries;
|
||||
const toolList = messageElement && messageElement.querySelector('.mcp-tool-list');
|
||||
@@ -3095,6 +3094,8 @@ function getMcpExecutionCount(messageElement) {
|
||||
const summaryCount = parseInt(messageElement.dataset.mcpExecutionCount, 10) || 0;
|
||||
if (summaryCount > 0) return summaryCount;
|
||||
}
|
||||
const pending = getPendingMcpExecutionCount(messageElement);
|
||||
if (pending > 0) return pending;
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -3185,6 +3186,10 @@ function setPendingMcpExecutionIds(messageElement, executionIds) {
|
||||
} else {
|
||||
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') {
|
||||
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) {
|
||||
if (!messageElement || !messageElement.dataset || !Array.isArray(summaries)) return;
|
||||
const normalized = cacheToolExecutionSummaries(messageElement, summaries);
|
||||
@@ -3260,7 +3277,6 @@ function setPendingToolExecutionSummaries(messageElement, summaries) {
|
||||
if (normalized.length > 0 && renderedToolList && renderedToolList.querySelector('.mcp-detail-btn')) {
|
||||
setMcpCallSummaries(messageElement, normalized);
|
||||
delete messageElement.dataset.pendingToolExecutionSummaries;
|
||||
delete messageElement.dataset.pendingMcpExecutionIds;
|
||||
}
|
||||
if (typeof syncMcpToolsToggleButton === 'function') {
|
||||
syncMcpToolsToggleButton(messageElement);
|
||||
@@ -3550,6 +3566,15 @@ async function fetchProcessDetailDataForModal(detailId) {
|
||||
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) {
|
||||
if (!resultData) return null;
|
||||
const displayState = typeof window.getToolResultDisplayState === 'function'
|
||||
@@ -3558,9 +3583,7 @@ function processToolResultToMCPResult(resultData, rawText) {
|
||||
const isError = !!displayState.isError;
|
||||
let text = rawText;
|
||||
if (text == null || String(text) === '') {
|
||||
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));
|
||||
text = typeof result === 'string' ? result : JSON.stringify(result);
|
||||
text = processToolResultTextFromData(resultData);
|
||||
}
|
||||
return {
|
||||
content: [{ type: 'text', text: String(text || '') }],
|
||||
@@ -3591,8 +3614,14 @@ async function showMCPDetailFromProcessToolItem(messageElement, summary, index)
|
||||
}
|
||||
}
|
||||
const resultDetailId = state.resultDetailId || target.dataset.toolResultDetailId || '';
|
||||
if (!resultData && resultDetailId) {
|
||||
resultData = await fetchProcessDetailDataForModal(resultDetailId);
|
||||
const resultPayloadDeferred = resultData && resultData._payloadDeferred === true;
|
||||
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 displayState = resultData && typeof window.getToolResultDisplayState === 'function'
|
||||
@@ -3639,7 +3668,7 @@ async function openTaskToolExecutionDetail(messageElement, item, index) {
|
||||
|
||||
/**
|
||||
* 声明式渲染工具调用列表。
|
||||
* 过程摘要是展示详情入口的唯一模型;executionIds 仅在摘要尚未到达时提供占位。
|
||||
* 过程摘要提供完整展示入口;executionIds 只补充可直接打开监控详情的 ID。
|
||||
* 每次更新整体替换列表,避免增量追加产生双重状态。
|
||||
*/
|
||||
function renderMcpCallButtons(messageElement) {
|
||||
@@ -3649,9 +3678,7 @@ function renderMcpCallButtons(messageElement) {
|
||||
const toolList = chrome.toolList;
|
||||
const executionIds = getCachedMcpExecutionIds(messageElement);
|
||||
const summaries = getCachedToolExecutionSummaries(messageElement);
|
||||
const items = summaries.length > 0
|
||||
? mergeToolExecutionSummariesWithIds(summaries, executionIds)
|
||||
: executionIds.map((executionId) => normalizeToolExecutionSummaryForButton({ executionId }));
|
||||
const items = selectToolExecutionSummariesForButtons(summaries, executionIds);
|
||||
|
||||
const renderVersion = String((parseInt(toolList.dataset.renderVersion, 10) || 0) + 1);
|
||||
toolList.dataset.renderVersion = renderVersion;
|
||||
@@ -3718,7 +3745,6 @@ function renderPendingMcpCallButtons(messageElement) {
|
||||
delete messageElement.dataset.pendingToolExecutionSummaries;
|
||||
}
|
||||
if (renderedSummaryExecutions) {
|
||||
delete messageElement.dataset.pendingMcpExecutionIds;
|
||||
return;
|
||||
}
|
||||
if (messageElement.dataset.pendingMcpExecutionIds) {
|
||||
@@ -3825,9 +3851,6 @@ async function batchUpdateButtonToolNames(buttonsContainer, executionIds, render
|
||||
}
|
||||
}
|
||||
|
||||
// 显示MCP调用详情
|
||||
const MCP_DETAIL_MAX_CHARS = 120000;
|
||||
|
||||
function extractMCPResultText(result) {
|
||||
if (!result) return '';
|
||||
const content = result.content;
|
||||
@@ -3844,37 +3867,18 @@ function extractMCPResultText(result) {
|
||||
return '';
|
||||
}
|
||||
|
||||
function truncateMCPDetailText(text, maxChars) {
|
||||
function formatMCPDetailText(text) {
|
||||
if (text == null) return '';
|
||||
const s = 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;
|
||||
return String(text);
|
||||
}
|
||||
|
||||
/** 响应结果区 JSON 展示(过大时截断 content 内 text,避免 stringify 卡死页面) */
|
||||
function formatMCPResultJsonForDisplay(result, maxChars) {
|
||||
function formatMCPResultJsonForDisplay(result) {
|
||||
if (!result) return '{}';
|
||||
const payload = {
|
||||
content: result.content,
|
||||
isError: !!result.isError
|
||||
};
|
||||
let json = 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;
|
||||
return JSON.stringify(payload, null, 2);
|
||||
}
|
||||
|
||||
function switchMCPResultDetailTab(tabName) {
|
||||
@@ -3962,12 +3966,12 @@ function renderMCPDetailModal(exec) {
|
||||
setMCPResultDetailTabs('raw', false);
|
||||
|
||||
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') : '执行成功,未返回可展示的文本内容。';
|
||||
|
||||
if (exec.result.isError) {
|
||||
responseElement.className = 'code-block error';
|
||||
responseElement.textContent = formatMCPResultJsonForDisplay(exec.result, MCP_DETAIL_MAX_CHARS);
|
||||
responseElement.textContent = formatMCPResultJsonForDisplay(exec.result);
|
||||
if (successElement) {
|
||||
successElement.textContent = '';
|
||||
}
|
||||
@@ -3978,7 +3982,7 @@ function renderMCPDetailModal(exec) {
|
||||
}
|
||||
} else {
|
||||
responseElement.className = 'code-block';
|
||||
responseElement.textContent = formatMCPResultJsonForDisplay(exec.result, MCP_DETAIL_MAX_CHARS);
|
||||
responseElement.textContent = formatMCPResultJsonForDisplay(exec.result);
|
||||
if (successElement) {
|
||||
successElement.textContent = agentVisibleText || emptyText;
|
||||
}
|
||||
@@ -4528,6 +4532,10 @@ async function loadConversation(conversationId) {
|
||||
// 更新当前对话ID
|
||||
currentConversationId = conversationId;
|
||||
window._loadedConversationProjectId = conversation.projectId || conversation.project_id || '';
|
||||
const conversationRoleName = conversation.roleName || conversation.role_name || '';
|
||||
if (typeof window.setCurrentRole === 'function') {
|
||||
window.setCurrentRole(conversationRoleName || '默认');
|
||||
}
|
||||
try {
|
||||
window.currentConversationId = conversationId;
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
@@ -295,14 +295,18 @@ async function refreshDashboard() {
|
||||
toolsTotalCalls = s.totalCalls || 0;
|
||||
toolsFailedCount = s.failedCalls || 0;
|
||||
const totalSuccess = s.successCalls || 0;
|
||||
const effectiveToolCalls = totalSuccess + toolsFailedCount;
|
||||
setEl('dashboard-kpi-tools-calls', formatNumber(toolsTotalCalls));
|
||||
setKpiSubText('dashboard-kpi-tools-sub-text',
|
||||
dt('dashboard.toolsCountLabel', { count: toolsCount }, toolsCount + ' 个工具'));
|
||||
if (toolsTotalCalls > 0) {
|
||||
toolsSuccessRate = (totalSuccess / toolsTotalCalls) * 100;
|
||||
if (effectiveToolCalls > 0) {
|
||||
toolsSuccessRate = (totalSuccess / effectiveToolCalls) * 100;
|
||||
const rateStr = toolsSuccessRate.toFixed(1) + '%';
|
||||
setEl('dashboard-kpi-success-rate', rateStr);
|
||||
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 {
|
||||
setEl('dashboard-kpi-success-rate', '-');
|
||||
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-ignored', formatNumber(ignored));
|
||||
|
||||
// 修复率:fixed / total(不计入 false_positive 时也可,按 total 维持一致)
|
||||
var t = Number(total || 0);
|
||||
var rate = t > 0 ? (fixed / t) * 100 : 0;
|
||||
var rateStr = t > 0 ? rate.toFixed(rate >= 100 ? 0 : 1) + '%' : '-';
|
||||
// 修复率只按需要处置的有效漏洞计算;误报/已忽略属于中性闭环,不拉低修复率。
|
||||
var actionableTotal = open + confirmed + fixed;
|
||||
var rate = actionableTotal > 0 ? (fixed / actionableTotal) * 100 : 0;
|
||||
var rateStr = actionableTotal > 0 ? rate.toFixed(rate >= 100 ? 0 : 1) + '%' : '-';
|
||||
setEl('dashboard-fix-rate', rateStr);
|
||||
|
||||
var detailEl = document.getElementById('dashboard-fix-detail');
|
||||
if (detailEl) {
|
||||
detailEl.textContent = '(' + formatNumber(fixed) + ' / ' + formatNumber(t) + ')';
|
||||
detailEl.textContent = '(' + formatNumber(fixed) + ' / ' + formatNumber(actionableTotal) + ')';
|
||||
}
|
||||
|
||||
var fixedPct = t > 0 ? (fixed / t) * 100 : 0;
|
||||
var confirmedPct = t > 0 ? (confirmed / t) * 100 : 0;
|
||||
var fixedPct = actionableTotal > 0 ? (fixed / actionableTotal) * 100 : 0;
|
||||
var confirmedPct = actionableTotal > 0 ? (confirmed / actionableTotal) * 100 : 0;
|
||||
var fixedBar = document.getElementById('dashboard-fix-progress-fixed');
|
||||
var confirmedBar = document.getElementById('dashboard-fix-progress-confirmed');
|
||||
if (fixedBar) fixedBar.style.width = fixedPct.toFixed(2) + '%';
|
||||
|
||||
@@ -5293,10 +5293,14 @@ function getMcpMonitorTimelineRange() {
|
||||
|
||||
function buildMonitorTotals(summary) {
|
||||
const s = summary && typeof summary === 'object' ? summary : {};
|
||||
const total = s.totalCalls || 0;
|
||||
const success = s.successCalls || 0;
|
||||
const failed = s.failedCalls || 0;
|
||||
return {
|
||||
total: s.totalCalls || 0,
|
||||
success: s.successCalls || 0,
|
||||
failed: s.failedCalls || 0,
|
||||
total,
|
||||
success,
|
||||
failed,
|
||||
neutral: Math.max(0, total - success - failed),
|
||||
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 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 neutralPill = mcpMonitorT('neutralCount', { n: totals.neutral }) || monitorFallback(`终止 ${totals.neutral}`, `Stopped ${totals.neutral}`);
|
||||
const rateValue = hasCalls ? `${successRate}%` : successRate;
|
||||
const neutralChip = totals.neutral > 0
|
||||
? `<span class="mcp-stats-kpi__chip is-neutral">${escapeHtml(neutralPill)}</span>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<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">
|
||||
<span class="mcp-stats-kpi__chip is-ok">${escapeHtml(successPill)}</span>
|
||||
<span class="mcp-stats-kpi__chip is-fail">${escapeHtml(failedPill)}</span>
|
||||
${neutralChip}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
@@ -6240,7 +6249,8 @@ function renderMcpStatsToolTable(topTools, totals, activeToolFilter = '') {
|
||||
const total = tool.totalCalls || 0;
|
||||
const success = tool.successCalls || 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 sharePct = totals.total > 0 ? ((total / totals.total) * 100).toFixed(1) : '0.0';
|
||||
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 success = tool.successCalls || 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 sharePct = totals.total > 0 ? ((total / totals.total) * 100).toFixed(1) : '0.0';
|
||||
const color = MCP_STATS_DIST_COLORS[index % MCP_STATS_DIST_COLORS.length];
|
||||
@@ -6453,17 +6464,19 @@ function renderMonitorStats(summary = null, topTools = [], lastFetchedAt = null)
|
||||
return;
|
||||
}
|
||||
|
||||
const hasCalls = totals.total > 0;
|
||||
const successRateNum = hasCalls ? (totals.success / totals.total) * 100 : 0;
|
||||
const effectiveTotal = totals.success + totals.failed;
|
||||
const hasCalls = effectiveTotal > 0;
|
||||
const successRateNum = hasCalls ? (totals.success / effectiveTotal) * 100 : 0;
|
||||
const successRate = hasCalls ? successRateNum.toFixed(1) : '-';
|
||||
const locale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : 'en-US';
|
||||
const noCallsYet = mcpMonitorT('noCallsYet') || monitorFallback('暂无调用', 'No calls yet');
|
||||
const noCompletedYet = mcpMonitorT('noCompletedYet') || monitorFallback('暂无有效完成', 'No completed outcomes yet');
|
||||
const lastCallText = totals.lastCallTime
|
||||
? (totals.lastCallTime.toLocaleString ? totals.lastCallTime.toLocaleString(locale) : String(totals.lastCallTime))
|
||||
: noCallsYet;
|
||||
|
||||
const rateTone = hasCalls ? getMcpStatsRateTone(successRateNum) : 'is-muted';
|
||||
let rateSubText = noCallsYet;
|
||||
let rateSubText = totals.total > 0 ? noCompletedYet : noCallsYet;
|
||||
if (hasCalls) {
|
||||
rateSubText = mcpMonitorT('rateHealthy') || monitorFallback('运行平稳', 'Running smoothly');
|
||||
if (successRateNum < 80) rateSubText = mcpMonitorT('rateCritical') || monitorFallback('失败率偏高', 'High failure rate');
|
||||
|
||||
@@ -1892,6 +1892,7 @@ function getCurrentRole() {
|
||||
// 暴露函数到全局作用域
|
||||
if (typeof window !== 'undefined') {
|
||||
window.getCurrentRole = getCurrentRole;
|
||||
window.setCurrentRole = handleRoleChange;
|
||||
window.toggleRoleSelectionPanel = toggleRoleSelectionPanel;
|
||||
window.closeRoleSelectionPanel = closeRoleSelectionPanel;
|
||||
window.closeRoleSelectModal = closeRoleSelectModal;
|
||||
@@ -1905,6 +1906,7 @@ if (typeof window !== 'undefined') {
|
||||
originalHandleRoleChange(roleName);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.currentSelectedRole = getCurrentRole();
|
||||
window.setCurrentRole = handleRoleChange;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2714,6 +2714,7 @@
|
||||
<div class="page-header">
|
||||
<h2 data-i18n="chatFilesPage.title">文件管理</h2>
|
||||
<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>
|
||||
<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>
|
||||
@@ -2724,11 +2725,24 @@
|
||||
<div class="tasks-filters chat-files-filters">
|
||||
<label>
|
||||
<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 style="flex:1;min-width:180px;max-width:360px;">
|
||||
<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>
|
||||
<span data-i18n="chatFilesPage.groupBy">分组</span>
|
||||
@@ -2736,10 +2750,11 @@
|
||||
<option value="none" data-i18n="chatFilesPage.groupNone">不分组</option>
|
||||
<option value="date" data-i18n="chatFilesPage.groupByDate">按日期</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>
|
||||
</select>
|
||||
</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 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>
|
||||
@@ -2748,6 +2763,7 @@
|
||||
<div id="chat-files-list-wrap" class="chat-files-table-wrap">
|
||||
<div class="loading-spinner" data-i18n="common.loading">加载中…</div>
|
||||
</div>
|
||||
<div id="chat-files-pagination" class="chat-files-pagination pagination" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user