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