Compare commits

...
10 Commits
Author SHA1 Message Date
公明andGitHub af4b25b84e Update config.example.yaml 2026-07-23 20:20:41 +08:00
公明andGitHub f0b1955059 Add files via upload 2026-07-23 20:18:55 +08:00
公明andGitHub f5d580bbf0 Add files via upload 2026-07-23 20:17:17 +08:00
公明andGitHub 44d069da2b Add files via upload 2026-07-23 20:14:45 +08:00
公明andGitHub 9297e6e6ee Add files via upload 2026-07-23 20:12:31 +08:00
公明andGitHub 9bbc28c14a Add files via upload 2026-07-23 20:10:57 +08:00
公明andGitHub 4943b9419e Add files via upload 2026-07-23 19:52:14 +08:00
公明andGitHub 446ccd3edb Add files via upload 2026-07-23 19:40:24 +08:00
公明andGitHub a00643b9c0 Add files via upload 2026-07-23 19:39:28 +08:00
公明andGitHub 5c13819f66 Add files via upload 2026-07-23 19:37:45 +08:00
19 changed files with 883 additions and 94 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
# ============================================
# 前端显示的版本号(可选,不填则显示默认版本)
version: "v1.7.8"
version: "v1.7.9"
# 服务器配置
server:
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
+19
View File
@@ -67,6 +67,25 @@ Streaming endpoints are long-lived. Clients should:
- disable proxy buffering;
- pass `conversationId` when continuing a conversation.
## File Management Sources
The file management page and `GET /api/chat-uploads` group conversation-related files by source. Directory names still use project IDs or conversation IDs for stability, while the UI prefers project names or conversation titles and keeps the full ID available in tooltips or copied paths.
| Source | `source` | Typical directory | Meaning | Mutability |
| --- | --- | --- | --- | --- |
| Workspace files | `workspace` | `tmp/workspace/projects/<projectId>/...`, `tmp/workspace/conversations/<conversationId>/...` | The Agent workspace for downloaded files, analysis scripts, intermediate results, and generated CSV/XLSX/Markdown files. If an AI-generated file is missing from the UI, check this source first. | Read-only listing; supports copy path, download, and export. |
| Conversation artifacts | `conversation_artifact` | `data/conversation_artifacts/<conversationId>/...` | Conversation-scoped deliverables or archived artifacts such as summaries, reports, or middleware-generated artifacts. | Read-only listing; supports copy path, download, and export. |
| Tool outputs | `reduction` | `tmp/reduction/projects/<projectId>/...`, `tmp/reduction/conversations/<conversationId>/...` | Persisted full tool outputs, scan raw data, or outputs saved before truncation. Useful for reviewing long command or scan results. | Read-only listing; supports copy path, download, and export. |
| Chat uploads | `upload` | `chat_uploads/<date>/<conversationId>/...` | Files manually uploaded in chat or from the file management page. Copy the server absolute path into chat when the AI should reference a file. | Supports upload, mkdir, text edit, rename, delete, copy path, download, and export. |
Related endpoints:
- `GET /api/chat-uploads`: list files filtered by source, project, conversation, or filename.
- `GET /api/chat-uploads/path`: resolve a file-management relative path or internal virtual path to a server absolute path for copy actions.
- `GET /api/chat-uploads/download`: download a file.
- `GET /api/chat-uploads/export`: export the current filtered result as a ZIP.
- `POST /api/chat-uploads`: upload into the chat uploads directory.
## Asset Management and Bulk Import
Asset endpoints:
+19
View File
@@ -74,6 +74,25 @@ Content-Type: application/json
- `POST /api/conversations/:id/delete-turn`
- `GET /api/messages/:id/process-details`
## 文件管理来源
文件管理页面和 `/api/chat-uploads` 列表接口会把对话相关文件按来源归类。底层目录仍使用项目 ID 或会话 ID 保持稳定,界面会优先显示项目名或对话标题,完整 ID 可在提示或路径中查看。
| 来源 | `source` | 典型目录 | 说明 | 可变更性 |
| --- | --- | --- | --- | --- |
| 工作目录 | `workspace` | `tmp/workspace/projects/<projectId>/...``tmp/workspace/conversations/<conversationId>/...` | Agent 执行任务时保存下载文件、分析脚本、中间结果和生成的 CSV/XLSX/Markdown 等。用户反馈“AI 生成的文件找不到”时,通常先看这里。 | 只读展示;支持复制路径、下载、导出。 |
| 会话产物 | `conversation_artifact` | `data/conversation_artifacts/<conversationId>/...` | 系统按会话归档的交付物或会话级产物,例如总结、报告、模型中间件生成的归档内容。 | 只读展示;支持复制路径、下载、导出。 |
| 工具输出 | `reduction` | `tmp/reduction/projects/<projectId>/...``tmp/reduction/conversations/<conversationId>/...` | 超长工具输出、扫描原文或被截断前落盘的结果缓存。适合回看完整工具输出。 | 只读展示;支持复制路径、下载、导出。 |
| 对话附件 | `upload` | `chat_uploads/<date>/<conversationId>/...` | 用户在对话或文件管理页手动上传的附件。需要让 AI 引用某文件时,可复制服务器绝对路径粘贴到对话中。 | 可上传、新建目录、编辑文本文件、重命名、删除、复制路径、下载、导出。 |
相关接口:
- `GET /api/chat-uploads`:按来源、项目、会话、文件名筛选文件。
- `GET /api/chat-uploads/path`:把文件管理中的相对路径或内部虚拟路径解析为服务器绝对路径,用于复制文件或目录路径。
- `GET /api/chat-uploads/download`:下载指定文件。
- `GET /api/chat-uploads/export`:导出当前筛选结果为 ZIP。
- `POST /api/chat-uploads`:上传到对话附件目录。
## 项目、漏洞、攻击链
项目:
+1
View File
@@ -1340,6 +1340,7 @@ func setupRoutes(
protected.GET("/chat-uploads", chatUploadsHandler.List)
protected.GET("/chat-uploads/export", chatUploadsHandler.Export)
protected.GET("/chat-uploads/download", chatUploadsHandler.Download)
protected.GET("/chat-uploads/path", chatUploadsHandler.ResolvePath)
protected.GET("/chat-uploads/content", chatUploadsHandler.GetContent)
protected.POST("/chat-uploads", chatUploadsHandler.Upload)
protected.POST("/chat-uploads/mkdir", chatUploadsHandler.Mkdir)
+9 -1
View File
@@ -75,6 +75,7 @@ tcp_reverse 默认仅接受 CSB1 加密 BeaconAES-GCM + ImplantToken)才登
"bind_host": map[string]interface{}{"type": "string", "description": "绑定地址,默认 127.0.0.1;外网监听常用 0.0.0.0"},
"callback_host": map[string]interface{}{"type": "string", "description": "可选:植入端/Payload 回连主机名(公网 IP 或域名)。写入 config_json;生成 oneliner/beacon 时优先于 bind_host。update 时传入空字符串可清除"},
"bind_port": map[string]interface{}{"type": "integer", "description": fmt.Sprintf("绑定端口(create 必填)。须 ≠ %d(当前本服务 Web/API 端口,配置 server.port", webListenPort), "minimum": 1, "maximum": 65535},
"project_id": map[string]interface{}{"type": "string", "description": "所属项目 ID。create 省略时默认使用当前对话绑定项目;未绑定项目的对话则创建未绑定监听器"},
"profile_id": map[string]interface{}{"type": "string", "description": "Malleable Profile ID"},
"remark": map[string]interface{}{"type": "string", "description": "备注"},
"config": map[string]interface{}{"type": "object", "description": "高级配置(beacon 路径/TLS/OPSEC 等),create/update 可用。tcp_reverse 可选 allow_legacy_shell:true 允许未加密经典 shell(默认 false"},
@@ -116,6 +117,13 @@ tcp_reverse 默认仅接受 CSB1 加密 BeaconAES-GCM + ImplantToken)才登
cfg = &c2.ListenerConfig{}
_ = json.Unmarshal(cfgBytes, cfg)
}
projectID := strings.TrimSpace(getString(params, "project_id"))
if projectID == "" {
projectID = mcpEffectiveProjectFilter(ctx, m.DB())
if projectID == database.ProjectFilterUnbound {
projectID = ""
}
}
input := c2.CreateListenerInput{
Name: getString(params, "name"),
Type: getString(params, "type"),
@@ -123,7 +131,7 @@ tcp_reverse 默认仅接受 CSB1 加密 BeaconAES-GCM + ImplantToken)才登
BindPort: int(getFloat64(params, "bind_port")),
ProfileID: getString(params, "profile_id"),
Remark: getString(params, "remark"),
ProjectID: strings.TrimSpace(mcp.MCPProjectIDFromContext(ctx)),
ProjectID: projectID,
Config: cfg,
CallbackHost: getString(params, "callback_host"),
}
+69
View File
@@ -0,0 +1,69 @@
package app
import (
"context"
"path/filepath"
"testing"
"cyberstrike-ai/internal/authctx"
"cyberstrike-ai/internal/c2"
"cyberstrike-ai/internal/database"
"cyberstrike-ai/internal/mcp"
"cyberstrike-ai/internal/mcp/builtin"
"go.uber.org/zap"
)
func TestC2ListenerCreateInheritsConversationProject(t *testing.T) {
db, err := database.NewDB(filepath.Join(t.TempDir(), "c2-tools.db"), zap.NewNop())
if err != nil {
t.Fatal(err)
}
defer db.Close()
user, err := db.CreateRBACUser("c2-agent", "C2 Agent", "hash", true, nil)
if err != nil {
t.Fatal(err)
}
project, err := db.CreateProject(&database.Project{Name: "engagement"})
if err != nil {
t.Fatal(err)
}
if err := db.AssignResourceToUser(user.ID, "project", project.ID); err != nil {
t.Fatal(err)
}
conversation, err := db.CreateConversation("project chat", database.ConversationCreateMeta{ProjectID: project.ID})
if err != nil {
t.Fatal(err)
}
principal := authctx.NewPrincipal(user.ID, user.Username, database.RBACScopeAssigned, map[string]bool{
"c2:read": true, "c2:write": true,
})
ctx := authctx.WithPrincipal(mcp.WithMCPConversationID(context.Background(), conversation.ID), principal)
server := mcp.NewServer(zap.NewNop())
server.SetToolAuthorizer(mcpToolAuthorizer(db))
registerC2Tools(server, c2.NewManager(db, zap.NewNop(), t.TempDir()), zap.NewNop(), 8080)
result, _, err := server.CallTool(ctx, builtin.ToolC2Listener, map[string]interface{}{
"action": "create",
"name": "tcp-reverse-2222",
"type": "tcp_reverse",
"bind_host": "0.0.0.0",
"bind_port": 2222,
})
if err != nil || result == nil || result.IsError {
t.Fatalf("create listener result=%#v err=%v text=%q", result, err, toolResultText(result))
}
listeners, err := db.ListC2Listeners()
if err != nil {
t.Fatal(err)
}
if len(listeners) != 1 {
t.Fatalf("listener count=%d, want 1", len(listeners))
}
if listeners[0].ProjectID != project.ID {
t.Fatalf("listener project_id=%q, want %q", listeners[0].ProjectID, project.ID)
}
}
+14 -1
View File
@@ -244,7 +244,20 @@ func authorizeC2Action(ctx context.Context, principal authctx.Principal, db *dat
return nil
}
if id == "" {
if action == "create" || action == "list" {
if action == "create" {
projectID := mcpAuthorizationString(args, "project_id")
if projectID == "" {
projectID = mcpEffectiveProjectFilter(ctx, db)
if projectID == database.ProjectFilterUnbound {
projectID = ""
}
}
if projectID != "" && (db == nil || !db.UserCanAccessResource(principal.UserID, principal.ScopeFor(permission), "project", projectID)) {
return fmt.Errorf("no access to project %s", projectID)
}
return nil
}
if action == "list" {
return nil
}
return fmt.Errorf("missing resource identifier %s", argument)
+5
View File
@@ -835,6 +835,11 @@ func (db *DB) ConversationArtifactsBaseDir() string {
return strings.TrimSpace(db.conversationArtifactsDir)
}
// EinoWorkspaceBaseDir returns the configured agent workspace root.
func (db *DB) EinoWorkspaceBaseDir() string {
return db.einoWorkspaceBaseDir()
}
func (db *DB) einoWorkspaceBaseDir() string {
if db == nil {
return ""
+13
View File
@@ -111,6 +111,19 @@ func (db *DB) GetProject(id string) (*Project, error) {
return &p, nil
}
// GetProjectName returns a project display name without loading the full record.
func (db *DB) GetProjectName(id string) (string, error) {
var name string
err := db.QueryRow(`SELECT name FROM projects WHERE id = ?`, id).Scan(&name)
if err != nil {
if err == sql.ErrNoRows {
return "", fmt.Errorf("项目不存在")
}
return "", fmt.Errorf("获取项目名称失败: %w", err)
}
return strings.TrimSpace(name), nil
}
func projectListSearchPattern(q string) string {
q = strings.TrimSpace(q)
if q == "" {
+347 -41
View File
@@ -27,11 +27,14 @@ import (
const (
chatUploadsRootDirName = "chat_uploads"
reductionRootDirName = "tmp/reduction"
workspaceRootDirName = "tmp/workspace"
artifactsRootDirName = "data/conversation_artifacts"
reductionVirtualPrefix = "__reduction__/"
workspaceVirtualPrefix = "__workspace__/"
artifactVirtualPrefix = "__conversation_artifact__/"
chatUploadSourceUpload = "upload"
chatUploadSourceReduction = "reduction"
chatUploadSourceWorkspace = "workspace"
chatUploadSourceConversation = "conversation_artifact"
maxChatUploadEditBytes = 2 * 1024 * 1024 // 文本编辑上限
)
@@ -104,6 +107,11 @@ func (h *ChatUploadsHandler) reductionPathAllowed(c *gin.Context, scope, id stri
func (h *ChatUploadsHandler) reductionVirtualPathAllowed(c *gin.Context, relativePath string) bool {
rel := strings.TrimPrefix(strings.TrimSpace(relativePath), reductionVirtualPrefix)
rel = strings.Trim(filepath.ToSlash(filepath.Clean(filepath.FromSlash(rel))), "/")
if rel == "projects" || rel == "conversations" {
_, ok := security.CurrentSession(c)
return ok
}
parts := strings.Split(filepath.ToSlash(rel), "/")
if len(parts) < 2 {
return false
@@ -111,6 +119,24 @@ func (h *ChatUploadsHandler) reductionVirtualPathAllowed(c *gin.Context, relativ
return h.reductionPathAllowed(c, parts[0], parts[1])
}
func (h *ChatUploadsHandler) workspacePathAllowed(c *gin.Context, scope, id string) bool {
return h.reductionPathAllowed(c, scope, id)
}
func (h *ChatUploadsHandler) workspaceVirtualPathAllowed(c *gin.Context, relativePath string) bool {
rel := strings.TrimPrefix(strings.TrimSpace(relativePath), workspaceVirtualPrefix)
rel = strings.Trim(filepath.ToSlash(filepath.Clean(filepath.FromSlash(rel))), "/")
if rel == "projects" || rel == "conversations" {
_, ok := security.CurrentSession(c)
return ok
}
parts := strings.Split(filepath.ToSlash(rel), "/")
if len(parts) < 2 {
return false
}
return h.workspacePathAllowed(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 {
@@ -163,6 +189,26 @@ func (h *ChatUploadsHandler) absReductionRoot() (string, error) {
return filepath.Abs(filepath.Join(cwd, reductionRootDirName))
}
func (h *ChatUploadsHandler) absWorkspaceRoot() (string, error) {
if h.db != nil {
if base := strings.TrimSpace(h.db.EinoWorkspaceBaseDir()); 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, workspaceRootDirName))
}
func (h *ChatUploadsHandler) absConversationArtifactsRoot() (string, error) {
if h.db != nil {
if base := strings.TrimSpace(h.db.ConversationArtifactsBaseDir()); base != "" {
@@ -211,15 +257,17 @@ func (h *ChatUploadsHandler) resolveUnderChatUploads(relativePath string) (abs s
// ChatUploadFileItem 列表项
type ChatUploadFileItem struct {
RelativePath string `json:"relativePath"`
AbsolutePath string `json:"absolutePath"` // 服务器上的绝对路径,便于在对话中引用(与附件落盘路径一致)
Name string `json:"name"`
Source string `json:"source,omitempty"`
Size int64 `json:"size"`
ModifiedUnix int64 `json:"modifiedUnix"`
Date string `json:"date"`
ConversationID string `json:"conversationId"`
ProjectID string `json:"projectId,omitempty"`
RelativePath string `json:"relativePath"`
AbsolutePath string `json:"absolutePath"` // 服务器上的绝对路径,便于在对话中引用(与附件落盘路径一致)
Name string `json:"name"`
Source string `json:"source,omitempty"`
Size int64 `json:"size"`
ModifiedUnix int64 `json:"modifiedUnix"`
Date string `json:"date"`
ConversationID string `json:"conversationId"`
ConversationTitle string `json:"conversationTitle,omitempty"`
ProjectID string `json:"projectId,omitempty"`
ProjectName string `json:"projectName,omitempty"`
// SubPath 为日期、会话目录之下的子路径(不含文件名),如 date/conv/a/b/file 则为 "a/b";无嵌套则为 ""。
SubPath string `json:"subPath"`
}
@@ -240,6 +288,38 @@ func (h *ChatUploadsHandler) conversationProjectID(conversationID string, cache
return projectID
}
func (h *ChatUploadsHandler) conversationTitle(conversationID string, cache map[string]string) string {
conversationID = strings.TrimSpace(conversationID)
if conversationID == "" || conversationID == "_manual" || conversationID == "_new" || h.db == nil {
return ""
}
if v, ok := cache[conversationID]; ok {
return v
}
title, err := h.db.GetConversationTitle(conversationID)
if err != nil {
title = ""
}
cache[conversationID] = title
return title
}
func (h *ChatUploadsHandler) projectName(projectID string, cache map[string]string) string {
projectID = strings.TrimSpace(projectID)
if projectID == "" || h.db == nil {
return ""
}
if v, ok := cache[projectID]; ok {
return v
}
name, err := h.db.GetProjectName(projectID)
if err != nil {
name = ""
}
cache[projectID] = name
return name
}
func (h *ChatUploadsHandler) collectFiles(c *gin.Context, conversationFilter, projectFilter string) ([]ChatUploadFileItem, []string, error) {
root, err := h.absRoot()
if err != nil {
@@ -252,6 +332,8 @@ func (h *ChatUploadsHandler) collectFiles(c *gin.Context, conversationFilter, pr
var files []ChatUploadFileItem
var folders []string
projectCache := make(map[string]string)
projectNameCache := make(map[string]string)
conversationTitleCache := make(map[string]string)
err = filepath.WalkDir(root, func(path string, d os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
@@ -293,16 +375,18 @@ func (h *ChatUploadsHandler) collectFiles(c *gin.Context, conversationFilter, pr
}
absPath, _ := filepath.Abs(path)
files = append(files, ChatUploadFileItem{
RelativePath: relSlash,
AbsolutePath: absPath,
Name: d.Name(),
Source: chatUploadSourceUpload,
Size: info.Size(),
ModifiedUnix: info.ModTime().Unix(),
Date: dateStr,
ConversationID: convID,
ProjectID: projectID,
SubPath: subPath,
RelativePath: relSlash,
AbsolutePath: absPath,
Name: d.Name(),
Source: chatUploadSourceUpload,
Size: info.Size(),
ModifiedUnix: info.ModTime().Unix(),
Date: dateStr,
ConversationID: convID,
ConversationTitle: h.conversationTitle(convID, conversationTitleCache),
ProjectID: projectID,
ProjectName: h.projectName(projectID, projectNameCache),
SubPath: subPath,
})
return nil
})
@@ -354,6 +438,12 @@ func (h *ChatUploadsHandler) collectFiles(c *gin.Context, conversationFilter, pr
} else if len(reductionFiles) > 0 {
files = append(files, reductionFiles...)
}
workspaceFiles, err := h.collectWorkspaceFiles(c, conversationFilter, projectFilter)
if err != nil {
h.logger.Warn("列举 workspace 产物失败", zap.Error(err))
} else if len(workspaceFiles) > 0 {
files = append(files, workspaceFiles...)
}
artifactFiles, err := h.collectConversationArtifactFiles(c, conversationFilter, projectFilter)
if err != nil {
h.logger.Warn("列举 conversation_artifacts 产物失败", zap.Error(err))
@@ -375,6 +465,8 @@ func (h *ChatUploadsHandler) collectReductionFiles(c *gin.Context, conversationF
return nil, nil
}
projectCache := make(map[string]string)
projectNameCache := make(map[string]string)
conversationTitleCache := make(map[string]string)
files := make([]ChatUploadFileItem, 0)
for _, scope := range []string{"conversations", "projects"} {
scopeRoot := filepath.Join(root, scope)
@@ -403,7 +495,10 @@ func (h *ChatUploadsHandler) collectReductionFiles(c *gin.Context, conversationF
projectID = ownerID
}
if conversationFilter != "" && convID != conversationFilter {
return nil
if scope != "projects" || h.conversationProjectID(conversationFilter, projectCache) != projectID {
return nil
}
convID = conversationFilter
}
if projectFilter != "" && projectID != projectFilter {
return nil
@@ -418,16 +513,90 @@ func (h *ChatUploadsHandler) collectReductionFiles(c *gin.Context, conversationF
}
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], "/"),
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,
ConversationTitle: h.conversationTitle(convID, conversationTitleCache),
ProjectID: projectID,
ProjectName: h.projectName(projectID, projectNameCache),
SubPath: strings.Join(parts[2:len(parts)-1], "/"),
})
return nil
})
}
return files, nil
}
func (h *ChatUploadsHandler) collectWorkspaceFiles(c *gin.Context, conversationFilter, projectFilter string) ([]ChatUploadFileItem, error) {
root, err := h.absWorkspaceRoot()
if err != nil {
return nil, err
}
if st, err := os.Stat(root); err != nil || !st.IsDir() {
return nil, nil
}
projectCache := make(map[string]string)
projectNameCache := make(map[string]string)
conversationTitleCache := 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.workspacePathAllowed(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 {
if scope != "projects" || h.conversationProjectID(conversationFilter, projectCache) != projectID {
return nil
}
convID = conversationFilter
}
if projectFilter != "" && projectID != projectFilter {
return nil
}
info, err := d.Info()
if err != nil {
return nil
}
abs, _ := filepath.Abs(path)
files = append(files, ChatUploadFileItem{
RelativePath: workspaceVirtualPrefix + relSlash,
AbsolutePath: abs,
Name: d.Name(),
Source: chatUploadSourceWorkspace,
Size: info.Size(),
ModifiedUnix: info.ModTime().Unix(),
Date: info.ModTime().Format("2006-01-02"),
ConversationID: convID,
ConversationTitle: h.conversationTitle(convID, conversationTitleCache),
ProjectID: projectID,
ProjectName: h.projectName(projectID, projectNameCache),
SubPath: strings.Join(parts[2:len(parts)-1], "/"),
})
return nil
})
@@ -444,6 +613,8 @@ func (h *ChatUploadsHandler) collectConversationArtifactFiles(c *gin.Context, co
return nil, nil
}
projectCache := make(map[string]string)
projectNameCache := make(map[string]string)
conversationTitleCache := 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() {
@@ -479,16 +650,18 @@ func (h *ChatUploadsHandler) collectConversationArtifactFiles(c *gin.Context, co
}
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], "/"),
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,
ConversationTitle: h.conversationTitle(convID, conversationTitleCache),
ProjectID: projectID,
ProjectName: h.projectName(projectID, projectNameCache),
SubPath: strings.Join(parts[1:len(parts)-1], "/"),
})
return nil
})
@@ -516,6 +689,27 @@ func (h *ChatUploadsHandler) resolveReductionVirtualPath(relativePath string) (s
return full, nil
}
func (h *ChatUploadsHandler) resolveWorkspaceVirtualPath(relativePath string) (string, error) {
rel := strings.TrimPrefix(strings.TrimSpace(relativePath), workspaceVirtualPrefix)
rel = filepath.Clean(filepath.FromSlash(rel))
if rel == "." || strings.HasPrefix(rel, "..") {
return "", fmt.Errorf("invalid path")
}
root, err := h.absWorkspaceRoot()
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 workspace 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))
@@ -539,8 +733,10 @@ func (h *ChatUploadsHandler) resolveConversationArtifactVirtualPath(relativePath
func chatUploadItemIsInternal(item ChatUploadFileItem) bool {
return item.Source == chatUploadSourceReduction ||
item.Source == chatUploadSourceWorkspace ||
item.Source == chatUploadSourceConversation ||
strings.HasPrefix(item.RelativePath, reductionVirtualPrefix) ||
strings.HasPrefix(item.RelativePath, workspaceVirtualPrefix) ||
strings.HasPrefix(item.RelativePath, artifactVirtualPrefix)
}
@@ -548,6 +744,8 @@ func (h *ChatUploadsHandler) resolveListedFilePath(item ChatUploadFileItem) (str
switch {
case item.Source == chatUploadSourceReduction || strings.HasPrefix(item.RelativePath, reductionVirtualPrefix):
return h.resolveReductionVirtualPath(item.RelativePath)
case item.Source == chatUploadSourceWorkspace || strings.HasPrefix(item.RelativePath, workspaceVirtualPrefix):
return h.resolveWorkspaceVirtualPath(item.RelativePath)
case item.Source == chatUploadSourceConversation || strings.HasPrefix(item.RelativePath, artifactVirtualPrefix):
return h.resolveConversationArtifactVirtualPath(item.RelativePath)
default:
@@ -705,7 +903,7 @@ func (h *ChatUploadsHandler) Export(c *gin.Context) {
"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},
"sourceDirectory": []string{chatUploadsRootDirName, reductionRootDirName, workspaceRootDirName, artifactsRootDirName},
}
manifestBytes, _ := json.MarshalIndent(manifest, "", " ")
mw, err := zw.Create("manifest.json")
@@ -732,6 +930,9 @@ func (h *ChatUploadsHandler) Export(c *gin.Context) {
if filepath.Ext(zipName) == "" {
zipName += ".txt"
}
} else if item.Source == chatUploadSourceWorkspace || strings.HasPrefix(item.RelativePath, workspaceVirtualPrefix) {
rel := strings.TrimPrefix(item.RelativePath, workspaceVirtualPrefix)
zipName = filepath.ToSlash(filepath.Join("internal", "workspace", rel))
} 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))
@@ -800,6 +1001,24 @@ func (h *ChatUploadsHandler) Download(c *gin.Context) {
c.FileAttachment(abs, name)
return
}
if strings.HasPrefix(strings.TrimSpace(p), workspaceVirtualPrefix) {
if !h.workspaceVirtualPathAllowed(c, p) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
abs, err := h.resolveWorkspaceVirtualPath(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
}
c.FileAttachment(abs, filepath.Base(abs))
return
}
if strings.HasPrefix(strings.TrimSpace(p), artifactVirtualPrefix) {
if !h.conversationArtifactVirtualPathAllowed(c, p) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
@@ -839,6 +1058,93 @@ func (h *ChatUploadsHandler) Download(c *gin.Context) {
c.FileAttachment(abs, filepath.Base(abs))
}
// ResolvePath GET /api/chat-uploads/path?path=...&kind=file|directory
func (h *ChatUploadsHandler) ResolvePath(c *gin.Context) {
p := strings.TrimSpace(c.Query("path"))
kind := strings.TrimSpace(c.Query("kind"))
if kind == "" {
kind = "file"
}
var abs string
var err error
switch {
case strings.HasPrefix(p, reductionVirtualPrefix):
if strings.Trim(strings.TrimPrefix(p, reductionVirtualPrefix), "/") == "" {
if _, ok := security.CurrentSession(c); !ok {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
abs, err = h.absReductionRoot()
break
}
if !h.reductionVirtualPathAllowed(c, p) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
abs, err = h.resolveReductionVirtualPath(p)
case strings.HasPrefix(p, workspaceVirtualPrefix):
if strings.Trim(strings.TrimPrefix(p, workspaceVirtualPrefix), "/") == "" {
if _, ok := security.CurrentSession(c); !ok {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
abs, err = h.absWorkspaceRoot()
break
}
if !h.workspaceVirtualPathAllowed(c, p) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
abs, err = h.resolveWorkspaceVirtualPath(p)
case strings.HasPrefix(p, artifactVirtualPrefix):
if strings.Trim(strings.TrimPrefix(p, artifactVirtualPrefix), "/") == "" {
if _, ok := security.CurrentSession(c); !ok {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
abs, err = h.absConversationArtifactsRoot()
break
}
if !h.conversationArtifactVirtualPathAllowed(c, p) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
abs, err = h.resolveConversationArtifactVirtualPath(p)
default:
if p == "" || p == "." {
if _, ok := security.CurrentSession(c); !ok {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
abs, err = h.absRoot()
break
}
if !h.pathAllowed(c, p) {
c.JSON(http.StatusForbidden, gin.H{"error": "无权访问该资源"})
return
}
abs, err = h.resolveUnderChatUploads(p)
}
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
st, err := os.Stat(abs)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "path not found"})
return
}
if kind == "directory" && !st.IsDir() {
c.JSON(http.StatusBadRequest, gin.H{"error": "not a directory"})
return
}
if kind != "directory" && st.IsDir() {
c.JSON(http.StatusBadRequest, gin.H{"error": "not a file"})
return
}
c.JSON(http.StatusOK, gin.H{"absolutePath": abs, "isDir": st.IsDir()})
}
type chatUploadPathBody struct {
Path string `json:"path"`
}
+45 -12
View File
@@ -5803,7 +5803,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"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": "source", "in": "query", "required": false, "description": "按来源过滤:upload/reduction/workspace/conversation_artifact/all", "schema": map[string]interface{}{"type": "string", "enum": []string{"all", "upload", "reduction", "workspace", "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"}}}}},
@@ -5821,16 +5821,18 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"items": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"relativePath": map[string]interface{}{"type": "string"},
"absolutePath": map[string]interface{}{"type": "string"},
"name": map[string]interface{}{"type": "string"},
"size": map[string]interface{}{"type": "integer"},
"modifiedUnix": map[string]interface{}{"type": "integer"},
"date": map[string]interface{}{"type": "string"},
"conversationId": map[string]interface{}{"type": "string"},
"projectId": map[string]interface{}{"type": "string"},
"subPath": map[string]interface{}{"type": "string"},
"source": map[string]interface{}{"type": "string", "description": "upload/reduction/conversation_artifact"},
"relativePath": map[string]interface{}{"type": "string"},
"absolutePath": map[string]interface{}{"type": "string"},
"name": map[string]interface{}{"type": "string"},
"size": map[string]interface{}{"type": "integer"},
"modifiedUnix": map[string]interface{}{"type": "integer"},
"date": map[string]interface{}{"type": "string"},
"conversationId": map[string]interface{}{"type": "string"},
"conversationTitle": map[string]interface{}{"type": "string"},
"projectId": map[string]interface{}{"type": "string"},
"projectName": map[string]interface{}{"type": "string"},
"subPath": map[string]interface{}{"type": "string"},
"source": map[string]interface{}{"type": "string", "description": "upload/reduction/workspace/conversation_artifact"},
},
},
},
@@ -5923,7 +5925,7 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
"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": "source", "in": "query", "required": false, "description": "按来源过滤:upload/reduction/workspace/conversation_artifact/all", "schema": map[string]interface{}{"type": "string", "enum": []string{"all", "upload", "reduction", "workspace", "conversation_artifact"}}},
{"name": "search", "in": "query", "required": false, "description": "按文件名或子路径搜索", "schema": map[string]interface{}{"type": "string"}},
},
"responses": map[string]interface{}{
@@ -5962,6 +5964,37 @@ func (h *OpenAPIHandler) GetOpenAPISpec(c *gin.Context) {
},
},
},
"/api/chat-uploads/path": map[string]interface{}{
"get": map[string]interface{}{
"tags": []string{"对话附件"},
"summary": "解析附件路径",
"description": "将文件管理中的相对路径或内部虚拟路径解析为服务器绝对路径,用于复制文件/目录路径。",
"operationId": "resolveChatUploadPath",
"parameters": []map[string]interface{}{
{"name": "path", "in": "query", "required": true, "description": "相对路径或虚拟路径(如 __workspace__/projects/<id>/csv", "schema": map[string]interface{}{"type": "string"}},
{"name": "kind", "in": "query", "required": false, "description": "路径类型:file/directory,默认 file", "schema": map[string]interface{}{"type": "string", "enum": []string{"file", "directory"}}},
},
"responses": map[string]interface{}{
"200": map[string]interface{}{
"description": "解析成功",
"content": map[string]interface{}{
"application/json": map[string]interface{}{
"schema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"absolutePath": map[string]interface{}{"type": "string"},
"isDir": map[string]interface{}{"type": "boolean"},
},
},
},
},
},
"401": map[string]interface{}{"description": "未授权"},
"403": map[string]interface{}{"description": "无权访问"},
"404": map[string]interface{}{"description": "路径不存在"},
},
},
},
"/api/chat-uploads/content": map[string]interface{}{
"get": map[string]interface{}{
"tags": []string{"对话附件"},
+127
View File
@@ -6,6 +6,8 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"testing"
"time"
@@ -146,6 +148,131 @@ func TestChatUploadPathAuthorizationFollowsConversationAccess(t *testing.T) {
}
}
func TestChatUploadsListIncludesAuthorizedProjectWorkspaceFiles(t *testing.T) {
db, user := setupConversationRBACTest(t)
fsBase := t.TempDir()
workspaceBase := filepath.Join(fsBase, "workspace")
reductionBase := filepath.Join(fsBase, "reduction")
db.SetEinoConversationDirs("", "", reductionBase, workspaceBase)
allowedProject, _ := db.CreateProject(&database.Project{Name: "allowed"})
hiddenProject, _ := db.CreateProject(&database.Project{Name: "hidden"})
conversation, _ := db.CreateConversation("project conversation", database.ConversationCreateMeta{ProjectID: allowedProject.ID})
if err := db.AssignResourceToUser(user.ID, "project", allowedProject.ID); err != nil {
t.Fatal(err)
}
allowedFile := filepath.Join(workspaceBase, "projects", allowedProject.ID, "csv", "assets.csv")
hiddenFile := filepath.Join(workspaceBase, "projects", hiddenProject.ID, "csv", "secret.csv")
for _, path := range []string{allowedFile, hiddenFile} {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("name\nexample\n"), 0o644); err != nil {
t.Fatal(err)
}
}
h := NewChatUploadsHandler(zap.NewNop(), db)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/api/chat-uploads?source=workspace&pageSize=all&conversation="+conversation.ID, nil)
c.Set(security.ContextSessionKey, security.Session{UserID: user.ID, Scope: database.RBACScopeAssigned})
h.List(c)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200: %s", w.Code, w.Body.String())
}
var response struct {
Files []ChatUploadFileItem `json:"files"`
Total int `json:"total"`
}
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if response.Total != 1 || len(response.Files) != 1 {
t.Fatalf("files = %#v, total = %d, want only authorized workspace file", response.Files, response.Total)
}
got := response.Files[0]
if got.Source != chatUploadSourceWorkspace || got.Name != "assets.csv" || got.ProjectID != allowedProject.ID {
t.Fatalf("workspace file = %#v", got)
}
if got.ProjectName != allowedProject.Name {
t.Fatalf("projectName = %q, want %q", got.ProjectName, allowedProject.Name)
}
if got.ConversationID != conversation.ID {
t.Fatalf("conversationId = %q, want %q", got.ConversationID, conversation.ID)
}
if got.ConversationTitle != conversation.Title {
t.Fatalf("conversationTitle = %q, want %q", got.ConversationTitle, conversation.Title)
}
if got.AbsolutePath != allowedFile {
t.Fatalf("absolutePath = %q, want %q", got.AbsolutePath, allowedFile)
}
w = httptest.NewRecorder()
c, _ = gin.CreateTestContext(w)
resolveURL := "/api/chat-uploads/path?kind=directory&path=__workspace__%2Fprojects%2F" + allowedProject.ID
c.Request = httptest.NewRequest(http.MethodGet, resolveURL, nil)
c.Set(security.ContextSessionKey, security.Session{UserID: user.ID, Scope: database.RBACScopeAssigned})
h.ResolvePath(c)
if w.Code != http.StatusOK {
t.Fatalf("resolve status = %d, want 200: %s", w.Code, w.Body.String())
}
var resolved struct {
AbsolutePath string `json:"absolutePath"`
IsDir bool `json:"isDir"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resolved); err != nil {
t.Fatal(err)
}
wantDir := filepath.Join(workspaceBase, "projects", allowedProject.ID)
if !resolved.IsDir || resolved.AbsolutePath != wantDir {
t.Fatalf("resolved = %#v, want dir %q", resolved, wantDir)
}
w = httptest.NewRecorder()
c, _ = gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/api/chat-uploads/path?kind=directory&path=__workspace__%2Fprojects", nil)
c.Set(security.ContextSessionKey, security.Session{UserID: user.ID, Scope: database.RBACScopeAssigned})
h.ResolvePath(c)
if w.Code != http.StatusOK {
t.Fatalf("resolve projects container status = %d, want 200: %s", w.Code, w.Body.String())
}
if err := json.Unmarshal(w.Body.Bytes(), &resolved); err != nil {
t.Fatal(err)
}
wantContainer := filepath.Join(workspaceBase, "projects")
if !resolved.IsDir || resolved.AbsolutePath != wantContainer {
t.Fatalf("resolved container = %#v, want dir %q", resolved, wantContainer)
}
for _, tc := range []struct {
path string
want string
}{
{"__workspace__/", workspaceBase},
{"__reduction__/", reductionBase},
{"__conversation_artifact__/", db.ConversationArtifactsBaseDir()},
} {
if err := os.MkdirAll(tc.want, 0o755); err != nil {
t.Fatal(err)
}
w = httptest.NewRecorder()
c, _ = gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/api/chat-uploads/path?kind=directory&path="+url.QueryEscape(tc.path), nil)
c.Set(security.ContextSessionKey, security.Session{UserID: user.ID, Scope: database.RBACScopeAssigned})
h.ResolvePath(c)
if w.Code != http.StatusOK {
t.Fatalf("resolve root %q status = %d, want 200: %s", tc.path, w.Code, w.Body.String())
}
if err := json.Unmarshal(w.Body.Bytes(), &resolved); err != nil {
t.Fatal(err)
}
wantAbs, _ := filepath.Abs(tc.want)
if !resolved.IsDir || resolved.AbsolutePath != wantAbs {
t.Fatalf("resolved root %q = %#v, want dir %q", tc.path, resolved, wantAbs)
}
}
}
func TestPrepareMultiAgentSessionRejectsForeignConversation(t *testing.T) {
db, user := setupConversationRBACTest(t)
hidden, _ := db.CreateConversation("hidden", database.ConversationCreateMeta{})
+6
View File
@@ -9860,6 +9860,12 @@ html[data-theme="dark"] .robot-binding-service-hint-icon {
display: block;
}
.settings-custom-select-menu--floating {
display: block;
position: fixed;
z-index: 10050;
}
.settings-custom-select-option {
display: flex;
align-items: center;
+2
View File
@@ -2369,6 +2369,7 @@
"sourceAll": "All",
"sourceUpload": "Chat uploads",
"sourceReduction": "Tool outputs",
"sourceWorkspace": "Workspace files",
"sourceConversationArtifact": "Conversation artifacts",
"groupBy": "Group by",
"groupNone": "None (flat list)",
@@ -2385,6 +2386,7 @@
"browseRoot": "Files",
"treeUploadsRoot": "Chat uploads",
"treeReductionRoot": "Tool outputs",
"treeWorkspaceRoot": "Workspace files",
"treeArtifactsRoot": "Conversation artifacts",
"browseUp": "Up",
"enterFolderTitle": "Open folder",
+2
View File
@@ -2357,6 +2357,7 @@
"sourceAll": "全部",
"sourceUpload": "对话附件",
"sourceReduction": "工具输出",
"sourceWorkspace": "工作目录",
"sourceConversationArtifact": "会话产物",
"groupBy": "分组方式",
"groupNone": "不分组(平铺)",
@@ -2373,6 +2374,7 @@
"browseRoot": "文件",
"treeUploadsRoot": "对话附件",
"treeReductionRoot": "工具输出",
"treeWorkspaceRoot": "工作目录",
"treeArtifactsRoot": "会话产物",
"browseUp": "上级",
"enterFolderTitle": "进入此文件夹",
+134 -28
View File
@@ -3,6 +3,8 @@
let chatFilesCache = [];
/** 后端 GET /api/chat-uploads 返回的目录相对路径(含空文件夹),与 files 合并成树 */
let chatFilesFoldersCache = [];
const chatFilesProjectNameById = {};
const chatFilesConversationTitleById = {};
let chatFilesDisplayed = [];
let chatFilesEditRelativePath = '';
let chatFilesRenameRelativePath = '';
@@ -16,6 +18,7 @@ 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_WORKSPACE_ROOT = 'workspace';
const CHAT_FILES_TREE_ARTIFACT_ROOT = 'conversation_artifacts';
/** 按文件夹浏览模式下的当前路径(虚拟根段数组),如 ['uploads','2024-03-21','uuid'] */
@@ -39,6 +42,19 @@ function closeAllChatFilesFilterSelects() {
});
}
function chatFilesRememberDisplayNames(files) {
Object.keys(chatFilesProjectNameById).forEach(function (k) { delete chatFilesProjectNameById[k]; });
Object.keys(chatFilesConversationTitleById).forEach(function (k) { delete chatFilesConversationTitleById[k]; });
(Array.isArray(files) ? files : []).forEach(function (f) {
const pid = String((f && f.projectId) || '').trim();
const pname = String((f && f.projectName) || '').trim();
if (pid && pname) chatFilesProjectNameById[pid] = pname;
const cid = String((f && f.conversationId) || '').trim();
const ctitle = String((f && f.conversationTitle) || '').trim();
if (cid && ctitle) chatFilesConversationTitleById[cid] = ctitle;
});
}
function syncChatFilesFilterSelect(selectId) {
const reg = chatFilesFilterSelectMap[selectId];
if (!reg) return;
@@ -389,6 +405,7 @@ async function loadChatFilesPage() {
}
const data = await res.json();
chatFilesCache = Array.isArray(data.files) ? data.files : [];
chatFilesRememberDisplayNames(chatFilesCache);
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;
@@ -708,6 +725,10 @@ function chatFilesTreePathForFile(f) {
rp = rp.replace(/^__reduction__\//, '');
return CHAT_FILES_TREE_REDUCTION_ROOT + '/' + rp;
}
if (source === 'workspace') {
rp = rp.replace(/^__workspace__\//, '');
return CHAT_FILES_TREE_WORKSPACE_ROOT + '/' + rp;
}
if (source === 'conversation_artifact') {
rp = rp.replace(/^__conversation_artifact__\//, '');
return CHAT_FILES_TREE_ARTIFACT_ROOT + '/' + rp;
@@ -768,6 +789,7 @@ function chatFilesEnsureSourceRoots(root) {
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 === 'workspace') roots.push(CHAT_FILES_TREE_WORKSPACE_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();
@@ -776,13 +798,16 @@ function chatFilesEnsureSourceRoots(root) {
function chatFilesIsInternalSource(f) {
const source = f && f.source;
return source === 'reduction' || source === 'conversation_artifact';
return source === 'reduction' || source === 'workspace' || source === 'conversation_artifact';
}
function chatFilesSourceLabel(source) {
if (source === 'reduction') {
return (typeof window.t === 'function') ? window.t('chatFilesPage.sourceReduction') : '工具输出';
}
if (source === 'workspace') {
return (typeof window.t === 'function') ? window.t('chatFilesPage.sourceWorkspace') : '工作目录';
}
if (source === 'conversation_artifact') {
return (typeof window.t === 'function') ? window.t('chatFilesPage.sourceConversationArtifact') : '会话产物';
}
@@ -808,12 +833,66 @@ function chatFilesTreeDisplayName(name) {
if (name === CHAT_FILES_TREE_REDUCTION_ROOT) {
return (typeof window.t === 'function') ? window.t('chatFilesPage.treeReductionRoot') : '工具输出';
}
if (name === CHAT_FILES_TREE_WORKSPACE_ROOT) {
return (typeof window.t === 'function') ? window.t('chatFilesPage.treeWorkspaceRoot') : '工作目录';
}
if (name === CHAT_FILES_TREE_ARTIFACT_ROOT) {
return (typeof window.t === 'function') ? window.t('chatFilesPage.treeArtifactsRoot') : '会话产物';
}
return name;
}
function chatFilesIDDisplay(id, labelMap, emptyLabel) {
const raw = id == null ? '' : String(id);
if (!raw || raw === '—') {
return { text: emptyLabel || '—', title: '' };
}
const label = String((labelMap && labelMap[raw]) || '').trim();
if (label) {
return { text: label, title: label + ' (' + raw + ')' };
}
if (raw.length > 36) {
return { text: raw.slice(0, 8) + '…' + raw.slice(-6), title: raw };
}
return { text: raw, title: raw };
}
function chatFilesConversationDisplay(id) {
const c = id == null ? '' : String(id);
if (typeof window.t === 'function') {
if (c === '_manual') {
return { text: window.t('chatFilesPage.convManual'), title: '_manual' };
}
if (c === '_new') {
return { text: window.t('chatFilesPage.convNew'), title: '_new' };
}
}
return chatFilesIDDisplay(c, chatFilesConversationTitleById);
}
function chatFilesProjectDisplay(id) {
const empty = (typeof window.t === 'function') ? window.t('chatFilesPage.projectUnbound') : '未绑定项目';
return chatFilesIDDisplay(id, chatFilesProjectNameById, empty);
}
function chatFilesTreePathDisplayName(pathParts) {
const parts = Array.isArray(pathParts) ? pathParts : [];
const name = parts.length ? parts[parts.length - 1] : '';
const root = parts[0] || '';
if ((root === CHAT_FILES_TREE_WORKSPACE_ROOT || root === CHAT_FILES_TREE_REDUCTION_ROOT) && parts.length === 3) {
if (parts[1] === 'projects') return chatFilesProjectDisplay(name);
if (parts[1] === 'conversations') return chatFilesConversationDisplay(name);
}
if (root === CHAT_FILES_TREE_ARTIFACT_ROOT && parts.length === 2) {
return chatFilesConversationDisplay(name);
}
if (root === CHAT_FILES_TREE_UPLOAD_ROOT && parts.length === 3) {
return chatFilesConversationDisplay(name);
}
const text = chatFilesTreeDisplayName(name);
return { text: text, title: String(name || '') };
}
function chatFilesUploadRelativeDirFromBrowsePath(path) {
const parts = Array.isArray(path) ? path.slice() : [];
if (parts[0] === CHAT_FILES_TREE_UPLOAD_ROOT) {
@@ -893,32 +972,15 @@ function chatFilesBuildGroups(files, mode) {
/** 分组标题:长 ID 缩短展示,完整值放在 title */
function chatFilesGroupHeadingID(key, emptyLabel) {
const c = key == null ? '' : String(key);
if (c === '' || c === '—') {
return { text: emptyLabel || '—', title: '' };
}
if (c.length > 36) {
return { text: c.slice(0, 8) + '…' + c.slice(-6), title: c };
}
return { text: c, title: c };
return chatFilesIDDisplay(key, null, emptyLabel);
}
function chatFilesGroupHeadingConversation(key) {
const c = key == null ? '' : String(key);
if (typeof window.t === 'function') {
if (c === '_manual') {
return { text: window.t('chatFilesPage.convManual'), title: '_manual' };
}
if (c === '_new') {
return { text: window.t('chatFilesPage.convNew'), title: '_new' };
}
}
return chatFilesGroupHeadingID(key);
return chatFilesConversationDisplay(key);
}
function chatFilesGroupHeadingProject(key) {
const empty = (typeof window.t === 'function') ? window.t('chatFilesPage.projectUnbound') : '未绑定项目';
return chatFilesGroupHeadingID(key, empty);
return chatFilesProjectDisplay(key);
}
function renderChatFilesTable() {
@@ -965,7 +1027,9 @@ function renderChatFilesTable() {
const isInternal = chatFilesIsInternalSource(f);
const sourceBadge = isInternal ? '<span class="chat-files-source-badge">' + escapeHtml(chatFilesSourceLabel(f.source)) + '</span>' : '';
const conv = f.conversationId || '';
const convEsc = escapeHtml(conv);
const convDisplay = chatFilesConversationDisplay(conv);
const convEsc = escapeHtml(convDisplay.text || conv);
const convTitleEsc = escapeHtml(convDisplay.title || conv);
const dt = f.modifiedUnix ? new Date(f.modifiedUnix * 1000).toLocaleString() : '—';
const canOpenChat = conv && conv !== '_manual' && conv !== '_new';
@@ -1011,7 +1075,7 @@ function renderChatFilesTable() {
return `<tr>
<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="${convTitleEsc}">${convEsc}</code></td>
<td class="chat-files-cell-subpath" title="${escapeHtml(subRaw || '')}">${subCellInner}</td>
<td class="chat-files-cell-name" title="${escapeHtml(pathForTitle)}">${nameEsc}${sourceBadge}</td>
<td>${formatChatFileBytes(f.size || 0)}</td>
@@ -1072,11 +1136,14 @@ function renderChatFilesTable() {
for (bi = 0; bi < chatFilesBrowsePath.length; bi++) {
const seg = chatFilesBrowsePath[bi];
const isLast = bi === chatFilesBrowsePath.length - 1;
const display = chatFilesTreePathDisplayName(chatFilesBrowsePath.slice(0, bi + 1));
const displayText = escapeHtml(display.text);
const displayTitle = display.title ? ' title="' + escapeHtml(display.title) + '"' : '';
breadcrumbHtml += '<span class="chat-files-breadcrumb-sep">/</span>';
if (isLast) {
breadcrumbHtml += '<span class="chat-files-breadcrumb-current">' + escapeHtml(chatFilesTreeDisplayName(seg)) + '</span>';
breadcrumbHtml += '<span class="chat-files-breadcrumb-current"' + displayTitle + '>' + displayText + '</span>';
} else {
breadcrumbHtml += '<button type="button" class="chat-files-breadcrumb-link" onclick="chatFilesNavigateBreadcrumb(' + bi + ')">' + escapeHtml(chatFilesTreeDisplayName(seg)) + '</button>';
breadcrumbHtml += '<button type="button" class="chat-files-breadcrumb-link" onclick="chatFilesNavigateBreadcrumb(' + bi + ')"' + displayTitle + '>' + displayText + '</button>';
}
}
breadcrumbHtml += '</nav>';
@@ -1098,6 +1165,8 @@ function renderChatFilesTable() {
function rowHtmlBrowseFolder(name) {
const nameAttr = encodeURIComponent(String(name));
const folderPath = chatFilesBrowsePath.concat([name]);
const folderDisplay = chatFilesTreePathDisplayName(folderPath);
const folderTitle = folderDisplay.title || tEnter;
const relToFolder = folderPath.join('/');
const uploadDirAttr = encodeURIComponent(relToFolder);
const canUploadFolder = chatFilesBrowseCanUploadToPath(folderPath);
@@ -1109,8 +1178,8 @@ function renderChatFilesTable() {
? `<button type="button" class="btn-icon btn-danger" title="${tDeleteFolder}" data-chat-folder-name="${nameAttr}" onclick="chatFilesDeleteFolderFromBtn(event, this)">${svgTrash}</button>`
: '';
return `<tr class="chat-files-tr-folder chat-files-tr-folder--nav" role="button" tabindex="0" data-chat-folder-name="${nameAttr}" onclick="chatFilesOnFolderRowClick(event)" onkeydown="chatFilesOnFolderRowKeydown(event)">
<td class="chat-files-tree-name-cell chat-files-tree-name-cell--folder" title="${tEnter}">
<span class="chat-files-tree-name-inner">${svgFolder}<span class="chat-files-tree-name-text">${escapeHtml(chatFilesTreeDisplayName(name))}</span></span>
<td class="chat-files-tree-name-cell chat-files-tree-name-cell--folder" title="${escapeHtml(folderTitle)}">
<span class="chat-files-tree-name-inner">${svgFolder}<span class="chat-files-tree-name-text">${escapeHtml(folderDisplay.text)}</span></span>
</td>
<td class="chat-files-tree-muted"></td>
<td class="chat-files-tree-muted"></td>
@@ -1351,7 +1420,25 @@ function chatFilesDeleteFolderFromBtn(ev, btn) {
async function copyChatFolderPathFromBrowse(folderName) {
const segs = chatFilesBrowsePath.concat([folderName]);
const text = chatFilesClipboardFolderPath(segs);
const relativePath = chatFilesRelativePathFromTreeSegments(segs);
let text = '';
if (relativePath) {
try {
const res = await apiFetch('/api/chat-uploads/path?kind=directory&path=' + encodeURIComponent(relativePath));
if (!res.ok) {
const raw = await res.text();
throw new Error(raw || String(res.status));
}
const data = await res.json();
text = String((data && data.absolutePath) || '').trim();
} catch (e) {
alert((e && e.message) ? e.message : String(e));
return;
}
}
if (!text) {
text = chatFilesClipboardFolderPath(segs);
}
const ok = await chatFilesCopyText(text);
if (ok) {
const msg = (typeof window.t === 'function') ? window.t('chatFilesPage.folderPathCopied') : '目录路径已复制';
@@ -1362,6 +1449,25 @@ async function copyChatFolderPathFromBrowse(folderName) {
}
}
function chatFilesRelativePathFromTreeSegments(segs) {
const parts = Array.isArray(segs) ? segs.slice() : [];
if (!parts.length) return '';
const root = parts.shift();
if (root === CHAT_FILES_TREE_UPLOAD_ROOT) {
return parts.length ? parts.join('/') : '.';
}
if (root === CHAT_FILES_TREE_REDUCTION_ROOT) {
return '__reduction__/' + parts.join('/');
}
if (root === CHAT_FILES_TREE_WORKSPACE_ROOT) {
return '__workspace__/' + parts.join('/');
}
if (root === CHAT_FILES_TREE_ARTIFACT_ROOT) {
return '__conversation_artifact__/' + parts.join('/');
}
return [root].concat(parts).join('/');
}
function chatFilesClipboardFolderPath(segs) {
const parts = Array.isArray(segs) ? segs.slice() : [];
if (!parts.length) return '';
+68 -5
View File
@@ -38,6 +38,16 @@ function closeSettingsCustomSelect(select) {
if (reg) {
reg.wrapper.classList.remove('open');
reg.trigger.setAttribute('aria-expanded', 'false');
if (reg.menu.parentNode !== reg.wrapper) {
reg.wrapper.appendChild(reg.menu);
}
reg.menu.classList.remove('settings-custom-select-menu--floating');
reg.menu.style.left = '';
reg.menu.style.right = '';
reg.menu.style.top = '';
reg.menu.style.bottom = '';
reg.menu.style.width = '';
reg.menu.style.maxHeight = '';
}
}
@@ -45,9 +55,62 @@ function closeAllSettingsCustomSelects() {
settingsCustomSelects.forEach((reg) => {
reg.wrapper.classList.remove('open');
reg.trigger.setAttribute('aria-expanded', 'false');
if (reg.menu.parentNode !== reg.wrapper) {
reg.wrapper.appendChild(reg.menu);
}
reg.menu.classList.remove('settings-custom-select-menu--floating');
reg.menu.style.left = '';
reg.menu.style.right = '';
reg.menu.style.top = '';
reg.menu.style.bottom = '';
reg.menu.style.width = '';
reg.menu.style.maxHeight = '';
});
}
function positionSettingsCustomSelectMenu(reg) {
if (!reg || !reg.wrapper.classList.contains('open')) return;
const rect = reg.trigger.getBoundingClientRect();
const viewportWidth = window.innerWidth || document.documentElement.clientWidth || 0;
const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0;
const gap = 6;
const edgePadding = 12;
const desiredWidth = Math.max(rect.width, 150);
const width = Math.min(desiredWidth, Math.max(160, viewportWidth - edgePadding * 2));
const left = Math.min(Math.max(edgePadding, rect.left), Math.max(edgePadding, viewportWidth - width - edgePadding));
const spaceBelow = viewportHeight - rect.bottom - gap - edgePadding;
const spaceAbove = rect.top - gap - edgePadding;
const openAbove = spaceBelow < 180 && spaceAbove > spaceBelow;
const maxHeight = Math.max(120, Math.floor((openAbove ? spaceAbove : spaceBelow) || 180));
reg.menu.style.left = `${Math.round(left)}px`;
reg.menu.style.right = 'auto';
reg.menu.style.width = `${Math.round(width)}px`;
reg.menu.style.maxHeight = `${maxHeight}px`;
if (openAbove) {
reg.menu.style.top = 'auto';
reg.menu.style.bottom = `${Math.round(viewportHeight - rect.top + gap)}px`;
} else {
reg.menu.style.top = `${Math.round(rect.bottom + gap)}px`;
reg.menu.style.bottom = 'auto';
}
}
function openSettingsCustomSelect(select) {
const reg = settingsCustomSelects.get(select);
if (!reg || select.disabled) return;
closeAllSettingsCustomSelects();
reg.wrapper.classList.add('open');
reg.trigger.setAttribute('aria-expanded', 'true');
reg.menu.classList.add('settings-custom-select-menu--floating');
document.body.appendChild(reg.menu);
positionSettingsCustomSelectMenu(reg);
}
function repositionOpenSettingsCustomSelects() {
settingsCustomSelects.forEach((reg) => positionSettingsCustomSelectMenu(reg));
}
function syncSettingsCustomSelect(select) {
const reg = settingsCustomSelects.get(select);
if (!reg) return;
@@ -139,9 +202,8 @@ function enhanceSettingsSelect(select) {
event.stopPropagation();
if (select.disabled) return;
const willOpen = !wrapper.classList.contains('open');
closeAllSettingsCustomSelects();
wrapper.classList.toggle('open', willOpen);
trigger.setAttribute('aria-expanded', willOpen ? 'true' : 'false');
if (willOpen) openSettingsCustomSelect(select);
else closeSettingsCustomSelect(select);
});
trigger.addEventListener('keydown', (event) => {
@@ -158,8 +220,7 @@ function enhanceSettingsSelect(select) {
closeSettingsCustomSelect(select);
return;
} else if (event.key === 'Enter' || event.key === ' ') {
wrapper.classList.add('open');
trigger.setAttribute('aria-expanded', 'true');
openSettingsCustomSelect(select);
event.preventDefault();
return;
} else {
@@ -200,6 +261,8 @@ function initSettingsCustomSelects(root) {
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') closeAllSettingsCustomSelects();
});
document.addEventListener('scroll', repositionOpenSettingsCustomSelects, true);
window.addEventListener('resize', repositionOpenSettingsCustomSelects);
settingsCustomSelectsDocBound = true;
}
refreshSettingsCustomSelects();
+1 -5
View File
@@ -4831,11 +4831,7 @@ function showAddWebshellModal() {
if (osSelEl) osSelEl.value = 'auto';
var encSelEl = document.getElementById('webshell-encoding');
if (encSelEl) encSelEl.value = 'auto';
var defaultProjectId = '';
try {
defaultProjectId = typeof getActiveProjectId === 'function' ? (getActiveProjectId() || '') : '';
} catch (e) {}
populateWebshellProjectSelect(defaultProjectId);
populateWebshellProjectSelect('');
document.getElementById('webshell-remark').value = '';
var titleEl = document.getElementById('webshell-modal-title');
if (titleEl) titleEl.textContent = wsT('webshell.addConnection');
+1
View File
@@ -2727,6 +2727,7 @@
<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="workspace" data-i18n="chatFilesPage.sourceWorkspace">工作目录</option>
<option value="conversation_artifact" data-i18n="chatFilesPage.sourceConversationArtifact">会话产物</option>
</select>
</label>