diff --git a/internal/handler/chat_uploads.go b/internal/handler/chat_uploads.go index 01b59789..af26654b 100644 --- a/internal/handler/chat_uploads.go +++ b/internal/handler/chat_uploads.go @@ -74,6 +74,8 @@ type ChatUploadFileItem struct { ModifiedUnix int64 `json:"modifiedUnix"` Date string `json:"date"` ConversationID string `json:"conversationId"` + // SubPath 为日期、会话目录之下的子路径(不含文件名),如 date/conv/a/b/file 则为 "a/b";无嵌套则为 ""。 + SubPath string `json:"subPath"` } // List GET /api/chat-uploads @@ -113,6 +115,10 @@ func (h *ChatUploadsHandler) List(c *gin.Context) { if len(parts) >= 3 { convID = parts[1] } + var subPath string + if len(parts) >= 4 { + subPath = strings.Join(parts[2:len(parts)-1], "/") + } if conversationFilter != "" && convID != conversationFilter { return nil } @@ -125,6 +131,7 @@ func (h *ChatUploadsHandler) List(c *gin.Context) { ModifiedUnix: info.ModTime().Unix(), Date: dateStr, ConversationID: convID, + SubPath: subPath, }) return nil }) @@ -171,7 +178,8 @@ func (h *ChatUploadsHandler) Delete(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - if err := os.Remove(abs); err != nil { + st, err := os.Stat(abs) + if err != nil { if os.IsNotExist(err) { c.JSON(http.StatusNotFound, gin.H{"error": "file not found"}) return @@ -179,6 +187,21 @@ func (h *ChatUploadsHandler) Delete(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + if st.IsDir() { + if err := os.RemoveAll(abs); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + } else { + if err := os.Remove(abs); err != nil { + if os.IsNotExist(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "file not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + } c.JSON(http.StatusOK, gin.H{"ok": true}) } @@ -295,30 +318,57 @@ func chatUploadShortRand(n int) string { return string(b) } -// Upload POST /api/chat-uploads (multipart: file, conversationId 可选) +// Upload POST /api/chat-uploads multipart: file;conversationId 可选;relativeDir 可选(chat_uploads 下目录的相对路径,将文件直接上传至该目录) func (h *ChatUploadsHandler) Upload(c *gin.Context) { fh, err := c.FormFile("file") if err != nil || fh == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "missing file"}) return } - convID := strings.TrimSpace(c.PostForm("conversationId")) - convDir := convID - if convDir == "" { - convDir = "_manual" - } else { - convDir = strings.ReplaceAll(convDir, string(filepath.Separator), "_") - } root, err := h.absRoot() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - dateStr := time.Now().Format("2006-01-02") - targetDir := filepath.Join(root, dateStr, convDir) - if err := os.MkdirAll(targetDir, 0755); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return + + var targetDir string + targetRel := strings.TrimSpace(c.PostForm("relativeDir")) + if targetRel != "" { + absDir, err := h.resolveUnderChatUploads(targetRel) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + st, err := os.Stat(absDir) + if err != nil { + if os.IsNotExist(err) { + if err := os.MkdirAll(absDir, 0755); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + } else { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + } else if !st.IsDir() { + c.JSON(http.StatusBadRequest, gin.H{"error": "relativeDir is not a directory"}) + return + } + targetDir = absDir + } else { + convID := strings.TrimSpace(c.PostForm("conversationId")) + convDir := convID + if convDir == "" { + convDir = "_manual" + } else { + convDir = strings.ReplaceAll(convDir, string(filepath.Separator), "_") + } + dateStr := time.Now().Format("2006-01-02") + targetDir = filepath.Join(root, dateStr, convDir) + if err := os.MkdirAll(targetDir, 0755); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } } baseName := filepath.Base(fh.Filename) if baseName == "" || baseName == "." { diff --git a/web/static/css/style.css b/web/static/css/style.css index 5dc12c84..ee75069b 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -12900,6 +12900,267 @@ header { border-radius: 8px; } +/* 分组视图:外层不再套一层大边框,由各分组卡片承担 */ +.chat-files-table-wrap.chat-files-table-wrap--grouped { + border: none; + background: transparent; + overflow: visible; +} + +/* GitHub 式:单表 + 首列缩进,无嵌套子表、无重复表头 */ +.chat-files-table-wrap.chat-files-table-wrap--tree { + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-primary); + overflow-x: auto; +} + +.chat-files-browse-wrap { + display: flex; + flex-direction: column; + gap: 0; +} + +.chat-files-browse-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px 16px; + padding: 10px 12px; + border-bottom: 1px solid var(--border-color); + background: var(--bg-secondary, #f8f9fa); +} + +.chat-files-breadcrumb { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 2px; + font-size: 0.8125rem; + line-height: 1.4; + flex: 1; + min-width: 0; +} + +.chat-files-breadcrumb-link { + border: none; + background: none; + padding: 2px 4px; + margin: 0; + font: inherit; + color: var(--accent-color); + cursor: pointer; + border-radius: 4px; + max-width: 100%; + text-align: left; + word-break: break-all; +} + +.chat-files-breadcrumb-link:hover { + text-decoration: underline; +} + +.chat-files-breadcrumb-sep { + color: var(--text-secondary); + user-select: none; + padding: 0 2px; +} + +.chat-files-breadcrumb-current { + color: var(--text-primary); + font-weight: 600; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + word-break: break-all; +} + +.chat-files-browse-up { + flex-shrink: 0; +} + +.chat-files-browse-up:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.chat-files-tr-folder--nav { + cursor: pointer; +} + +.chat-files-tr-folder--nav:hover { + background: rgba(0, 102, 255, 0.06); +} + +.chat-files-folder-empty { + text-align: center; + color: var(--text-secondary); + padding: 24px 12px !important; +} + +.chat-files-table--tree-flat { + font-size: 0.8125rem; +} + +.chat-files-table--tree-flat thead th { + background: var(--bg-secondary, #f8f9fa); +} + +.chat-files-table--tree-flat .chat-files-tr-folder { + background: var(--bg-primary); +} + +.chat-files-table--tree-flat .chat-files-tr-folder .chat-files-tree-name-cell--folder { + font-weight: 600; + color: var(--text-primary); +} + +.chat-files-table--tree-flat .chat-files-tr-file:hover { + background: rgba(128, 128, 128, 0.04); +} + +.chat-files-tree-icon { + flex-shrink: 0; + color: var(--accent-color); + opacity: 1; +} + +.chat-files-tree-icon path { + fill: var(--bg-primary); + stroke: var(--accent-color); +} + +.chat-files-tree-file-icon { + flex-shrink: 0; + color: var(--text-secondary); + opacity: 0.85; +} + +.chat-files-tree-name-cell { + max-width: min(100%, 560px); + vertical-align: middle; +} + +.chat-files-tree-name-inner { + display: inline-flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.chat-files-tree-name-text { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.8125rem; + word-break: break-all; + line-height: 1.35; +} + +.chat-files-tree-muted { + color: var(--text-secondary); + font-size: 0.8125rem; +} + +.chat-files-path-breadcrumb { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 2px; + line-height: 1.45; + max-width: 100%; +} + +.chat-files-path-sep { + color: var(--text-secondary); + font-weight: 500; + user-select: none; + padding: 0 2px; +} + +.chat-files-path-crumb { + color: var(--text-primary); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.8rem; +} + +.chat-files-path-root { + color: var(--text-secondary); + font-size: 0.8125rem; +} + +.chat-files-grouped { + display: flex; + flex-direction: column; + gap: 10px; +} + +.chat-files-group { + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-primary); + overflow: hidden; +} + +.chat-files-group > summary.chat-files-group-summary { + list-style: none; + cursor: pointer; + display: flex; + align-items: center; + gap: 12px; + padding: 10px 14px; + background: var(--bg-secondary, #f8f9fa); + font-weight: 600; + font-size: 0.875rem; + color: var(--text-primary); + user-select: none; +} + +.chat-files-group > summary.chat-files-group-summary::-webkit-details-marker { + display: none; +} + +.chat-files-group > summary.chat-files-group-summary::before { + content: ''; + display: inline-block; + width: 0; + height: 0; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-top: 6px solid var(--text-secondary); + margin-right: 2px; + transform: rotate(-90deg); + transition: transform 0.15s ease; + flex-shrink: 0; +} + +.chat-files-group[open] > summary.chat-files-group-summary::before { + transform: rotate(0deg); +} + +.chat-files-group-title { + flex: 1; + min-width: 0; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.8125rem; +} + +.chat-files-group-count { + flex-shrink: 0; + font-weight: 500; + color: var(--text-secondary); + font-size: 0.8125rem; +} + +.chat-files-group-body { + overflow-x: auto; + border-top: 1px solid var(--border-color); +} + +.chat-files-group-body .chat-files-table { + border-radius: 0; +} + +.chat-files-group-body .chat-files-table th { + background: var(--bg-primary); +} + .chat-files-table { width: 100%; border-collapse: collapse; @@ -12941,6 +13202,19 @@ header { vertical-align: bottom; } +.chat-files-cell-subpath { + max-width: 280px; + font-size: 0.8125rem; + color: var(--text-secondary); + vertical-align: middle; +} + +.chat-files-group-title--folder { + white-space: normal; + word-break: break-all; + line-height: 1.35; +} + .chat-files-actions { display: flex; flex-wrap: nowrap; diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index 50b0ac7a..2d4b6f0a 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -1007,6 +1007,25 @@ "conversationPlaceholder": "Leave empty for all", "searchName": "File name", "searchNamePlaceholder": "Filter by file name", + "groupBy": "Group by", + "groupNone": "None (flat list)", + "groupByDate": "By date", + "groupByConversation": "By conversation", + "groupByFolder": "By folder (path navigation)", + "browseRoot": "chat_uploads", + "browseUp": "Up", + "enterFolderTitle": "Open folder", + "copyFolderPathTitle": "Copy relative path under chat_uploads/…", + "folderPathCopied": "Folder path copied — paste into chat if needed", + "folderEmpty": "This folder is empty", + "confirmDeleteFolder": "Delete this folder and everything inside it? This cannot be undone.", + "deleteFolderTitle": "Delete folder", + "uploadToFolderTitle": "Upload file into this folder", + "colSubPath": "Subfolder", + "folderRoot": "(root)", + "groupCount": "{{count}} files", + "convManual": "Manual upload", + "convNew": "New chat", "colDate": "Date", "colConversation": "Conversation", "colName": "Name", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index f8f52c88..74cd4154 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -1007,6 +1007,25 @@ "conversationPlaceholder": "留空表示全部", "searchName": "文件名", "searchNamePlaceholder": "筛选文件名", + "groupBy": "分组方式", + "groupNone": "不分组(平铺)", + "groupByDate": "按日期", + "groupByConversation": "按会话", + "groupByFolder": "按文件夹(路径浏览)", + "browseRoot": "chat_uploads", + "browseUp": "上级", + "enterFolderTitle": "进入此文件夹", + "copyFolderPathTitle": "复制该目录的相对路径(chat_uploads/…)", + "folderPathCopied": "目录路径已复制,可粘贴到对话中", + "folderEmpty": "此文件夹为空", + "confirmDeleteFolder": "确定删除该文件夹及其中的全部文件?此操作不可恢复。", + "deleteFolderTitle": "删除文件夹", + "uploadToFolderTitle": "上传文件到此文件夹", + "colSubPath": "子路径", + "folderRoot": "(根目录)", + "groupCount": "{{count}} 个文件", + "convManual": "手动上传", + "convNew": "新对话", "colDate": "日期", "colConversation": "会话", "colName": "文件名", diff --git a/web/static/js/chat-files.js b/web/static/js/chat-files.js index 44222507..c6e75717 100644 --- a/web/static/js/chat-files.js +++ b/web/static/js/chat-files.js @@ -5,8 +5,76 @@ let chatFilesDisplayed = []; let chatFilesEditRelativePath = ''; let chatFilesRenameRelativePath = ''; +const CHAT_FILES_GROUP_STORAGE_KEY = 'csai_chat_files_group_by'; +const CHAT_FILES_BROWSE_PATH_KEY = 'csai_chat_files_browse_path'; + +/** 按文件夹浏览模式下的当前路径(相对 chat_uploads 的段数组),如 ['2024-03-21','uuid'] */ +let chatFilesBrowsePath = []; +/** 非空时,下一次上传文件落到此相对路径(chat_uploads 下目录),如 2026-03-21/uuid/sub */ +let chatFilesPendingUploadDir = ''; + +function chatFilesLoadBrowsePathFromStorage() { + try { + const raw = localStorage.getItem(CHAT_FILES_BROWSE_PATH_KEY); + if (!raw) { + chatFilesBrowsePath = []; + return; + } + const p = JSON.parse(raw); + if (Array.isArray(p) && p.every(function (x) { + return typeof x === 'string'; + })) { + chatFilesBrowsePath = p; + } + } catch (e) { + chatFilesBrowsePath = []; + } +} + +function chatFilesSetBrowsePath(path) { + chatFilesBrowsePath = path.slice(); + try { + localStorage.setItem(CHAT_FILES_BROWSE_PATH_KEY, JSON.stringify(chatFilesBrowsePath)); + } catch (e) { + /* ignore */ + } +} + +function chatFilesResolveTreeNode(root, path) { + let node = root; + let i; + for (i = 0; i < path.length; i++) { + const seg = path[i]; + if (!node.dirs[seg]) return null; + node = node.dirs[seg]; + } + return node; +} + +function chatFilesNormalizeBrowsePathForTree(root) { + let path = chatFilesBrowsePath.slice(); + while (path.length > 0 && !chatFilesResolveTreeNode(root, path)) { + path.pop(); + } + if (path.length !== chatFilesBrowsePath.length) { + chatFilesSetBrowsePath(path); + } +} + function initChatFilesPage() { + chatFilesLoadBrowsePathFromStorage(); ensureChatFilesDocClickClose(); + const sel = document.getElementById('chat-files-group-by'); + if (sel) { + try { + const v = localStorage.getItem(CHAT_FILES_GROUP_STORAGE_KEY); + if (v === 'none' || v === 'date' || v === 'conversation' || v === 'folder') { + sel.value = v; + } + } catch (e) { + /* ignore */ + } + } loadChatFilesPage(); } @@ -95,6 +163,8 @@ function ensureChatFilesDocClickClose() { async function loadChatFilesPage() { const wrap = document.getElementById('chat-files-list-wrap'); if (!wrap) return; + wrap.classList.remove('chat-files-table-wrap--grouped'); + wrap.classList.remove('chat-files-table-wrap--tree'); wrap.innerHTML = '
加载中…
'; if (typeof window.applyTranslations === 'function') { window.applyTranslations(wrap); @@ -118,6 +188,8 @@ async function loadChatFilesPage() { renderChatFilesTable(); } catch (e) { console.error(e); + wrap.classList.remove('chat-files-table-wrap--grouped'); + wrap.classList.remove('chat-files-table-wrap--tree'); const msg = (typeof window.t === 'function') ? window.t('chatFilesPage.errorLoad') : '加载失败'; wrap.innerHTML = '
' + escapeHtml(msg + ': ' + (e.message || String(e))) + '
'; } @@ -127,7 +199,11 @@ function chatFilesNameFilter(files) { const el = document.getElementById('chat-files-filter-name'); const q = el ? el.value.trim().toLowerCase() : ''; if (!q) return files; - return files.filter((f) => (f.name || '').toLowerCase().includes(q)); + return files.filter(function (f) { + const name = (f.name || '').toLowerCase(); + const sub = (f.subPath || '').toLowerCase(); + return name.includes(q) || sub.includes(q); + }); } /** 仅前端按文件名筛选,不重新请求 */ @@ -236,6 +312,144 @@ function chatFilesAlertMessage(raw) { return s || ((typeof window.t === 'function') ? window.t('chatFilesPage.errorGeneric') : '操作失败'); } +function chatFilesGetGroupByMode() { + const sel = document.getElementById('chat-files-group-by'); + const v = sel ? sel.value : 'none'; + if (v === 'date' || v === 'conversation' || v === 'folder') return v; + return 'none'; +} + +function chatFilesGroupByChange() { + const sel = document.getElementById('chat-files-group-by'); + if (sel) { + try { + localStorage.setItem(CHAT_FILES_GROUP_STORAGE_KEY, sel.value); + } catch (e) { + /* ignore */ + } + } + renderChatFilesTable(); +} + +function chatFilesCompareDateKeysDesc(a, b) { + const as = String(a); + const bs = String(b); + if (as === '—' && bs !== '—') return 1; + if (bs === '—' && as !== '—') return -1; + return bs.localeCompare(as); +} + +/** 目录树节点:dirs[段名] -> 子节点;files: { idx, name }[] */ +function chatFilesTreeMakeNode() { + return { dirs: {}, files: [] }; +} + +function chatFilesTreeInsertFile(root, f, idx) { + const rp = String(f.relativePath || '').replace(/\\/g, '/').replace(/^\/+/, ''); + if (!rp) return; + const parts = rp.split('/').filter(function (p) { + return p.length > 0; + }); + if (parts.length < 2) return; + let node = root; + for (let i = 0; i < parts.length - 1; i++) { + const seg = parts[i]; + if (!node.dirs[seg]) node.dirs[seg] = chatFilesTreeMakeNode(); + node = node.dirs[seg]; + } + node.files.push({ idx: idx, name: parts[parts.length - 1] }); +} + +function chatFilesBuildTree(files) { + const root = chatFilesTreeMakeNode(); + files.forEach(function (f, idx) { + chatFilesTreeInsertFile(root, f, idx); + }); + return root; +} + +function chatFilesTreeNodeMaxMod(node) { + let m = 0; + let i; + for (i = 0; i < node.files.length; i++) { + const f = chatFilesDisplayed[node.files[i].idx]; + m = Math.max(m, (f && f.modifiedUnix) || 0); + } + const keys = Object.keys(node.dirs); + for (i = 0; i < keys.length; i++) { + m = Math.max(m, chatFilesTreeNodeMaxMod(node.dirs[keys[i]])); + } + return m; +} + +function chatFilesTreeSortDirKeys(node, keys) { + return keys.slice().sort(function (a, b) { + const ma = chatFilesTreeNodeMaxMod(node.dirs[a]); + const mb = chatFilesTreeNodeMaxMod(node.dirs[b]); + if (mb !== ma) return mb - ma; + return String(a).localeCompare(String(b)); + }); +} + +function chatFilesBuildGroups(files, mode) { + const map = new Map(); + files.forEach(function (f, idx) { + const key = mode === 'date' ? (f.date || '—') : (f.conversationId || '—'); + if (!map.has(key)) { + map.set(key, { key: key, items: [] }); + } + map.get(key).items.push({ idx: idx, f: f }); + }); + const groups = Array.from(map.values()); + groups.forEach(function (g) { + g.items.sort(function (a, b) { + return (b.f.modifiedUnix || 0) - (a.f.modifiedUnix || 0); + }); + }); + if (mode === 'date') { + groups.sort(function (a, b) { + return chatFilesCompareDateKeysDesc(a.key, b.key); + }); + } else { + groups.sort(function (a, b) { + const ma = Math.max.apply( + null, + a.items.map(function (x) { + return x.f.modifiedUnix || 0; + }) + ); + const mb = Math.max.apply( + null, + b.items.map(function (x) { + return x.f.modifiedUnix || 0; + }) + ); + return mb - ma; + }); + } + return groups; +} + +/** 分组标题:会话 ID 过长时缩短展示,完整值放在 title */ +function chatFilesGroupHeadingConversation(key) { + const c = key == null ? '' : String(key); + if (c === '' || c === '—') { + return { text: '—', title: '' }; + } + 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' }; + } + } + if (c.length > 36) { + return { text: c.slice(0, 8) + '…' + c.slice(-6), title: c }; + } + return { text: c, title: c }; +} + function renderChatFilesTable() { const wrap = document.getElementById('chat-files-list-wrap'); if (!wrap) return; @@ -243,6 +457,8 @@ function renderChatFilesTable() { chatFilesDisplayed = chatFilesNameFilter(chatFilesCache); const emptyMsg = (typeof window.t === 'function') ? window.t('chatFilesPage.empty') : '暂无文件'; if (!chatFilesDisplayed.length) { + wrap.classList.remove('chat-files-table-wrap--grouped'); + wrap.classList.remove('chat-files-table-wrap--tree'); wrap.innerHTML = '
' + escapeHtml(emptyMsg) + '
'; if (typeof window.applyTranslations === 'function') { window.applyTranslations(wrap); @@ -252,6 +468,7 @@ function renderChatFilesTable() { const thDate = (typeof window.t === 'function') ? window.t('chatFilesPage.colDate') : '日期'; const thConv = (typeof window.t === 'function') ? window.t('chatFilesPage.colConversation') : '会话'; + const thSubPath = (typeof window.t === 'function') ? window.t('chatFilesPage.colSubPath') : '子路径'; const thName = (typeof window.t === 'function') ? window.t('chatFilesPage.colName') : '文件名'; const thSize = (typeof window.t === 'function') ? window.t('chatFilesPage.colSize') : '大小'; const thModified = (typeof window.t === 'function') ? window.t('chatFilesPage.colModified') : '修改时间'; @@ -260,12 +477,14 @@ function renderChatFilesTable() { const svgCopy = ''; const svgDownload = ''; const svgMore = ''; + const svgFolder = ''; + const svgFile = ''; const tCopyTitle = escapeHtml((typeof window.t === 'function') ? window.t('chatFilesPage.copyPathTitle') : '复制服务器上的绝对路径,可粘贴到对话中引用'); const tDlTitle = escapeHtml((typeof window.t === 'function') ? window.t('chatFilesPage.download') : '下载'); const tMoreTitle = escapeHtml((typeof window.t === 'function') ? window.t('chatFilesPage.moreActions') : '更多操作'); - const rows = chatFilesDisplayed.map((f, idx) => { + function rowHtml(f, idx) { const rp = f.relativePath || ''; const pathForTitle = (f.absolutePath && String(f.absolutePath).trim()) ? String(f.absolutePath).trim() : rp; const nameEsc = escapeHtml(f.name || ''); @@ -295,9 +514,25 @@ function renderChatFilesTable() { menuParts.push(``); const menuHtml = menuParts.join(''); + const subRaw = (f.subPath && String(f.subPath).trim()) ? String(f.subPath).trim() : ''; + const rootLabel = (typeof window.t === 'function') ? window.t('chatFilesPage.folderRoot') : '(根目录)'; + let subCellInner; + if (subRaw) { + const segs = subRaw.split('/').filter(function (s) { + return s.length > 0; + }); + subCellInner = '' + segs.map(function (seg, i) { + return (i > 0 ? '' : '') + + '' + escapeHtml(seg) + ''; + }).join('') + ''; + } else { + subCellInner = '' + escapeHtml(rootLabel) + ''; + } + return ` ${escapeHtml(f.date || '—')} ${convEsc} + ${subCellInner} ${nameEsc} ${formatChatFileBytes(f.size || 0)} ${escapeHtml(dt)} @@ -312,20 +547,302 @@ function renderChatFilesTable() { `; - }).join(''); + } - ensureChatFilesDocClickClose(); - - wrap.innerHTML = ` + const theadHtml = ` + - ${rows}
${escapeHtml(thDate)} ${escapeHtml(thConv)}${escapeHtml(thSubPath)} ${escapeHtml(thName)} ${escapeHtml(thSize)} ${escapeHtml(thModified)} ${escapeHtml(thActions)}
`; + `; + + const theadCompact = ` + ${escapeHtml(thName)} + ${escapeHtml(thSize)} + ${escapeHtml(thModified)} + ${escapeHtml(thActions)} + `; + + const groupMode = chatFilesGetGroupByMode(); + let innerHtml; + + if (groupMode === 'folder') { + const root = chatFilesBuildTree(chatFilesDisplayed); + chatFilesNormalizeBrowsePathForTree(root); + const node = chatFilesResolveTreeNode(root, chatFilesBrowsePath); + const current = node || root; + const dirKeys = chatFilesTreeSortDirKeys(current, Object.keys(current.dirs)); + + current.files.sort(function (a, b) { + 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 tUp = escapeHtml((typeof window.t === 'function') ? window.t('chatFilesPage.browseUp') : '上级'); + 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 tEnter = escapeHtml((typeof window.t === 'function') ? window.t('chatFilesPage.enterFolderTitle') : '进入'); + + let breadcrumbHtml = ''; + + const upDisabled = chatFilesBrowsePath.length === 0 ? ' disabled' : ''; + const toolbarHtml = '
' + breadcrumbHtml + + '
'; + + const svgTrash = ''; + const svgUploadToFolder = ''; + const tDeleteFolder = escapeHtml((typeof window.t === 'function') ? window.t('chatFilesPage.deleteFolderTitle') : '删除文件夹'); + const tUploadToFolder = escapeHtml((typeof window.t === 'function') ? window.t('chatFilesPage.uploadToFolderTitle') : '上传到此文件夹'); + + function rowHtmlBrowseFolder(name) { + const nameAttr = encodeURIComponent(String(name)); + const relToFolder = chatFilesBrowsePath.concat([name]).join('/'); + const uploadDirAttr = encodeURIComponent(relToFolder); + return ` + + ${svgFolder}${escapeHtml(name)} + + — + — + +
+ + + +
+ + `; + } + + function rowHtmlTreeFile(f, idx) { + const pathForTitle = (f.absolutePath && String(f.absolutePath).trim()) ? String(f.absolutePath).trim() : (f.relativePath || ''); + const nameEsc = escapeHtml(f.name || ''); + const dt = f.modifiedUnix ? new Date(f.modifiedUnix * 1000).toLocaleString() : '—'; + const conv = f.conversationId || ''; + const canOpenChat = conv && conv !== '_manual' && conv !== '_new'; + + const bin = chatFileIsBinaryByName(f.name); + const editHint = escapeHtml(chatFilesEditBlockedHint()); + const editUnavailable = (typeof window.t === 'function') ? escapeHtml(window.t('chatFilesPage.editUnavailable')) : '不可编辑'; + const tEdit = (typeof window.t === 'function') ? escapeHtml(window.t('chatFilesPage.edit')) : '编辑'; + const tOpenChat = (typeof window.t === 'function') ? escapeHtml(window.t('chatFilesPage.openChat')) : '打开对话'; + const tRename = (typeof window.t === 'function') ? escapeHtml(window.t('chatFilesPage.rename')) : '重命名'; + const tDelete = (typeof window.t === 'function') ? escapeHtml(window.t('common.delete')) : '删除'; + + const menuParts = []; + if (canOpenChat) { + menuParts.push(``); + } + if (!bin) { + menuParts.push(``); + } else { + menuParts.push(`
${editUnavailable}
`); + } + menuParts.push(``); + menuParts.push(``); + const menuHtml = menuParts.join(''); + + return ` + + ${svgFile}${nameEsc} + + ${formatChatFileBytes(f.size || 0)} + ${escapeHtml(dt)} + +
+ + +
+ + +
+
+ + `; + } + + const folderRows = dirKeys.map(rowHtmlBrowseFolder).join(''); + const fileRows = current.files.map(function (item) { + return rowHtmlTreeFile(chatFilesDisplayed[item.idx], item.idx); + }).join(''); + + let bodyRows = folderRows + fileRows; + if (!bodyRows) { + bodyRows = '' + tEmpty + ''; + } + + innerHtml = '
' + toolbarHtml + '' + theadCompact + '' + bodyRows + '
'; + } else if (groupMode === 'none') { + const rows = chatFilesDisplayed.map(function (f, idx) { + return rowHtml(f, idx); + }).join(''); + innerHtml = `${theadHtml}${rows}
`; + } else { + const groups = chatFilesBuildGroups(chatFilesDisplayed, groupMode); + const blocks = groups.map(function (g) { + const rows = g.items.map(function (item) { + return rowHtml(item.f, item.idx); + }).join(''); + let summaryMain; + let summaryTitleAttr = ''; + if (groupMode === 'date') { + summaryMain = escapeHtml(String(g.key)); + } else { + const h = chatFilesGroupHeadingConversation(g.key); + summaryMain = escapeHtml(h.text); + summaryTitleAttr = h.title ? ' title="' + escapeHtml(h.title) + '"' : ''; + } + const n = g.items.length; + const countLabel = (typeof window.t === 'function') + ? escapeHtml(window.t('chatFilesPage.groupCount', { count: n })) + : escapeHtml(String(n)); + return `
+ + ${summaryMain} + ${countLabel} + +
+ ${theadHtml}${rows}
+
+
`; + }).join(''); + innerHtml = `
${blocks}
`; + } + + ensureChatFilesDocClickClose(); + + wrap.innerHTML = innerHtml; + wrap.classList.toggle('chat-files-table-wrap--grouped', groupMode !== 'none' && groupMode !== 'folder'); + wrap.classList.toggle('chat-files-table-wrap--tree', groupMode === 'folder'); } +window.chatFilesGroupByChange = chatFilesGroupByChange; + +function chatFilesNavigateInto(name) { + const root = chatFilesBuildTree(chatFilesDisplayed); + chatFilesNormalizeBrowsePathForTree(root); + const next = chatFilesBrowsePath.concat([name]); + if (!chatFilesResolveTreeNode(root, next)) return; + chatFilesSetBrowsePath(next); + renderChatFilesTable(); +} + +function chatFilesNavigateBreadcrumb(level) { + const root = chatFilesBuildTree(chatFilesDisplayed); + chatFilesNormalizeBrowsePathForTree(root); + if (level < 0) { + chatFilesSetBrowsePath([]); + } else { + chatFilesSetBrowsePath(chatFilesBrowsePath.slice(0, level + 1)); + } + renderChatFilesTable(); +} + +function chatFilesNavigateUp() { + if (chatFilesBrowsePath.length === 0) return; + chatFilesSetBrowsePath(chatFilesBrowsePath.slice(0, -1)); + renderChatFilesTable(); +} + +function chatFilesFolderNameFromRow(el) { + if (!el || !el.getAttribute) return ''; + try { + return decodeURIComponent(String(el.getAttribute('data-chat-folder-name') || '')); + } catch (e) { + return ''; + } +} + +function chatFilesOnFolderRowClick(ev) { + if (ev.target.closest && ev.target.closest('[data-chat-files-stop]')) return; + const name = chatFilesFolderNameFromRow(ev.currentTarget); + if (!name) return; + chatFilesNavigateInto(name); +} + +function chatFilesOnFolderRowKeydown(ev) { + if (ev.key !== 'Enter' && ev.key !== ' ') return; + ev.preventDefault(); + const name = chatFilesFolderNameFromRow(ev.currentTarget); + if (!name) return; + chatFilesNavigateInto(name); +} + +function chatFilesCopyFolderPathFromBtn(ev, btn) { + if (ev) ev.stopPropagation(); + const name = chatFilesFolderNameFromRow(btn); + if (!name) return; + copyChatFolderPathFromBrowse(name); +} + +async function deleteChatFolderFromBrowse(folderName) { + const segs = chatFilesBrowsePath.concat([folderName]); + const rel = segs.join('/'); + const q = (typeof window.t === 'function') ? window.t('chatFilesPage.confirmDeleteFolder') : '确定删除该文件夹及其中的全部文件?'; + if (!confirm(q)) return; + try { + const res = await apiFetch('/api/chat-uploads', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: rel }) + }); + if (!res.ok) { + throw new Error(await res.text()); + } + loadChatFilesPage(); + } catch (e) { + alert((e && e.message) ? e.message : String(e)); + } +} + +function chatFilesDeleteFolderFromBtn(ev, btn) { + if (ev) ev.stopPropagation(); + const name = chatFilesFolderNameFromRow(btn); + if (!name) return; + deleteChatFolderFromBrowse(name); +} + +async function copyChatFolderPathFromBrowse(folderName) { + const segs = chatFilesBrowsePath.concat([folderName]); + const rel = segs.join('/'); + const text = rel ? ('chat_uploads/' + rel.replace(/^\/+/, '')) : 'chat_uploads'; + const ok = await chatFilesCopyText(text); + if (ok) { + const msg = (typeof window.t === 'function') ? window.t('chatFilesPage.folderPathCopied') : '目录路径已复制'; + chatFilesShowToast(msg); + } else { + const fail = (typeof window.t === 'function') ? window.t('common.copyFailed') : '复制失败'; + alert(fail); + } +} + +window.chatFilesNavigateInto = chatFilesNavigateInto; +window.chatFilesNavigateBreadcrumb = chatFilesNavigateBreadcrumb; +window.chatFilesNavigateUp = chatFilesNavigateUp; +window.chatFilesOnFolderRowClick = chatFilesOnFolderRowClick; +window.copyChatFolderPathFromBrowse = copyChatFolderPathFromBrowse; +window.chatFilesOnFolderRowKeydown = chatFilesOnFolderRowKeydown; +window.chatFilesCopyFolderPathFromBtn = chatFilesCopyFolderPathFromBtn; +window.chatFilesDeleteFolderFromBtn = chatFilesDeleteFolderFromBtn; +window.chatFilesOpenUploadPicker = chatFilesOpenUploadPicker; +window.chatFilesUploadToFolderClick = chatFilesUploadToFolderClick; + function openChatFilesConversationIdx(idx) { const f = chatFilesDisplayed[idx]; if (!f || !f.conversationId) return; @@ -501,15 +1018,41 @@ async function submitChatFilesRename() { } } +function chatFilesOpenUploadPicker() { + chatFilesPendingUploadDir = ''; + const inp = document.getElementById('chat-files-upload-input'); + if (inp) inp.click(); +} + +function chatFilesUploadToFolderClick(ev, btn) { + if (ev) ev.stopPropagation(); + const raw = btn.getAttribute('data-upload-dir'); + if (!raw) return; + try { + chatFilesPendingUploadDir = decodeURIComponent(raw); + } catch (e) { + chatFilesPendingUploadDir = ''; + return; + } + const inp = document.getElementById('chat-files-upload-input'); + if (inp) inp.click(); +} + async function onChatFilesUploadPick(ev) { const input = ev.target; const file = input && input.files && input.files[0]; if (!file) return; const form = new FormData(); form.append('file', file); - const conv = document.getElementById('chat-files-filter-conv'); - if (conv && conv.value.trim()) { - form.append('conversationId', conv.value.trim()); + const pendingDir = chatFilesPendingUploadDir; + chatFilesPendingUploadDir = ''; + if (pendingDir) { + form.append('relativeDir', pendingDir); + } else { + const conv = document.getElementById('chat-files-filter-conv'); + if (conv && conv.value.trim()) { + form.append('conversationId', conv.value.trim()); + } } try { const res = await apiFetch('/api/chat-uploads', { method: 'POST', body: form }); diff --git a/web/templates/index.html b/web/templates/index.html index 774ecd95..417e135d 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -1013,7 +1013,7 @@