From 2545774187028e580b98db9a1ef25882ca89f1a6 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=85=AC=E6=98=8E?=
<83812544+Ed1s0nZ@users.noreply.github.com>
Date: Sat, 21 Mar 2026 21:49:19 +0800
Subject: [PATCH] Add files via upload
---
internal/handler/chat_uploads.go | 78 ++++-
web/static/css/style.css | 274 +++++++++++++++
web/static/i18n/en-US.json | 19 ++
web/static/i18n/zh-CN.json | 19 ++
web/static/js/chat-files.js | 563 ++++++++++++++++++++++++++++++-
web/templates/index.html | 11 +-
6 files changed, 939 insertions(+), 25 deletions(-)
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 = `
| ${escapeHtml(thDate)} |
${escapeHtml(thConv)} |
+ ${escapeHtml(thSubPath)} |
${escapeHtml(thName)} |
${escapeHtml(thSize)} |
${escapeHtml(thModified)} |
${escapeHtml(thActions)} |
-
${rows}
`;
+ `;
+
+ 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 = ``;
+ } 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}
+
+
+ `;
+ }).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 @@