Add files via upload

This commit is contained in:
公明
2026-03-21 21:49:19 +08:00
committed by GitHub
parent 4bc62773a9
commit 2545774187
6 changed files with 939 additions and 25 deletions
+64 -14
View File
@@ -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: fileconversationId 可选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 == "." {
+274
View File
@@ -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;
+19
View File
@@ -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",
+19
View File
@@ -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": "文件名",
+553 -10
View File
@@ -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 = '<div class="loading-spinner" data-i18n="common.loading">加载中…</div>';
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 = '<div class="error-message">' + escapeHtml(msg + ': ' + (e.message || String(e))) + '</div>';
}
@@ -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 = '<div class="empty-state" data-i18n="chatFilesPage.empty">' + escapeHtml(emptyMsg) + '</div>';
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 = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
const svgDownload = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
const svgMore = '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg>';
const svgFolder = '<svg class="chat-files-tree-icon" width="16" height="16" viewBox="0 0 24 24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>';
const svgFile = '<svg class="chat-files-tree-file-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>';
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(`<button type="button" class="chat-files-dropdown-item is-danger" onclick="chatFilesCloseAllMenus(); deleteChatFileIdx(${idx});">${tDelete}</button>`);
const menuHtml = menuParts.join('');
const subRaw = (f.subPath && String(f.subPath).trim()) ? String(f.subPath).trim() : '';
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 = '<span class="chat-files-path-breadcrumb">' + segs.map(function (seg, i) {
return (i > 0 ? '<span class="chat-files-path-sep"></span>' : '') +
'<span class="chat-files-path-crumb">' + escapeHtml(seg) + '</span>';
}).join('') + '</span>';
} else {
subCellInner = '<span class="chat-files-path-root">' + escapeHtml(rootLabel) + '</span>';
}
return `<tr>
<td>${escapeHtml(f.date || '—')}</td>
<td class="chat-files-cell-conv"><code title="${convEsc}">${convEsc}</code></td>
<td class="chat-files-cell-subpath" title="${escapeHtml(subRaw || '')}">${subCellInner}</td>
<td class="chat-files-cell-name" title="${escapeHtml(pathForTitle)}">${nameEsc}</td>
<td>${formatChatFileBytes(f.size || 0)}</td>
<td>${escapeHtml(dt)}</td>
@@ -312,20 +547,302 @@ function renderChatFilesTable() {
</div>
</td>
</tr>`;
}).join('');
}
ensureChatFilesDocClickClose();
wrap.innerHTML = `<table class="chat-files-table"><thead><tr>
const theadHtml = `<thead><tr>
<th>${escapeHtml(thDate)}</th>
<th>${escapeHtml(thConv)}</th>
<th>${escapeHtml(thSubPath)}</th>
<th>${escapeHtml(thName)}</th>
<th>${escapeHtml(thSize)}</th>
<th>${escapeHtml(thModified)}</th>
<th>${escapeHtml(thActions)}</th>
</tr></thead><tbody>${rows}</tbody></table>`;
</tr></thead>`;
const theadCompact = `<thead><tr>
<th>${escapeHtml(thName)}</th>
<th>${escapeHtml(thSize)}</th>
<th>${escapeHtml(thModified)}</th>
<th>${escapeHtml(thActions)}</th>
</tr></thead>`;
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 = '<nav class="chat-files-breadcrumb" aria-label="breadcrumb">';
breadcrumbHtml += '<button type="button" class="chat-files-breadcrumb-link" onclick="chatFilesNavigateBreadcrumb(-1)">' + tRoot + '</button>';
let bi;
for (bi = 0; bi < chatFilesBrowsePath.length; bi++) {
const seg = chatFilesBrowsePath[bi];
const isLast = bi === chatFilesBrowsePath.length - 1;
breadcrumbHtml += '<span class="chat-files-breadcrumb-sep">/</span>';
if (isLast) {
breadcrumbHtml += '<span class="chat-files-breadcrumb-current">' + escapeHtml(seg) + '</span>';
} else {
breadcrumbHtml += '<button type="button" class="chat-files-breadcrumb-link" onclick="chatFilesNavigateBreadcrumb(' + bi + ')">' + escapeHtml(seg) + '</button>';
}
}
breadcrumbHtml += '</nav>';
const upDisabled = chatFilesBrowsePath.length === 0 ? ' disabled' : '';
const toolbarHtml = '<div class="chat-files-browse-toolbar">' + breadcrumbHtml +
'<button type="button" class="btn-secondary chat-files-browse-up"' + upDisabled + ' onclick="chatFilesNavigateUp()">' + tUp + '</button></div>';
const svgTrash = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>';
const svgUploadToFolder = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>';
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 `<tr class="chat-files-tr-folder chat-files-tr-folder--nav" role="button" tabindex="0" data-chat-folder-name="${nameAttr}" onclick="chatFilesOnFolderRowClick(event)" onkeydown="chatFilesOnFolderRowKeydown(event)">
<td class="chat-files-tree-name-cell chat-files-tree-name-cell--folder" title="${tEnter}">
<span class="chat-files-tree-name-inner">${svgFolder}<span class="chat-files-tree-name-text">${escapeHtml(name)}</span></span>
</td>
<td class="chat-files-tree-muted">—</td>
<td class="chat-files-tree-muted">—</td>
<td class="chat-files-actions" data-chat-files-stop="true" onclick="event.stopPropagation()">
<div class="chat-files-action-bar">
<button type="button" class="btn-icon" title="${tUploadToFolder}" data-upload-dir="${uploadDirAttr}" onclick="chatFilesUploadToFolderClick(event, this)">${svgUploadToFolder}</button>
<button type="button" class="btn-icon" title="${tCopyFolder}" data-chat-folder-name="${nameAttr}" onclick="chatFilesCopyFolderPathFromBtn(event, this)">${svgCopy}</button>
<button type="button" class="btn-icon btn-danger" title="${tDeleteFolder}" data-chat-folder-name="${nameAttr}" onclick="chatFilesDeleteFolderFromBtn(event, this)">${svgTrash}</button>
</div>
</td>
</tr>`;
}
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(`<button type="button" class="chat-files-dropdown-item" onclick="chatFilesCloseAllMenus(); openChatFilesConversationIdx(${idx});">${tOpenChat}</button>`);
}
if (!bin) {
menuParts.push(`<button type="button" class="chat-files-dropdown-item" onclick="chatFilesCloseAllMenus(); openChatFilesEditIdx(${idx});">${tEdit}</button>`);
} else {
menuParts.push(`<div class="chat-files-dropdown-item is-disabled" title="${editHint}">${editUnavailable}</div>`);
}
menuParts.push(`<button type="button" class="chat-files-dropdown-item" onclick="chatFilesCloseAllMenus(); openChatFilesRenameIdx(${idx});">${tRename}</button>`);
menuParts.push(`<button type="button" class="chat-files-dropdown-item is-danger" onclick="chatFilesCloseAllMenus(); deleteChatFileIdx(${idx});">${tDelete}</button>`);
const menuHtml = menuParts.join('');
return `<tr class="chat-files-tr-file">
<td class="chat-files-tree-name-cell" title="${escapeHtml(pathForTitle)}">
<span class="chat-files-tree-name-inner">${svgFile}<span class="chat-files-tree-name-text">${nameEsc}</span></span>
</td>
<td>${formatChatFileBytes(f.size || 0)}</td>
<td>${escapeHtml(dt)}</td>
<td class="chat-files-actions">
<div class="chat-files-action-bar">
<button type="button" class="btn-icon" title="${tCopyTitle}" onclick="copyChatFilePathIdx(${idx})">${svgCopy}</button>
<button type="button" class="btn-icon" title="${tDlTitle}" onclick="downloadChatFileIdx(${idx})">${svgDownload}</button>
<div class="chat-files-dropdown-wrap">
<button type="button" class="btn-icon" title="${tMoreTitle}" aria-haspopup="true" onclick="chatFilesToggleMoreMenu(event, ${idx})">${svgMore}</button>
<div class="chat-files-dropdown" id="chat-files-menu-${idx}" hidden>${menuHtml}</div>
</div>
</div>
</td>
</tr>`;
}
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 = '<tr class="chat-files-tr-empty"><td colspan="4" class="chat-files-folder-empty">' + tEmpty + '</td></tr>';
}
innerHtml = '<div class="chat-files-browse-wrap">' + toolbarHtml + '<table class="chat-files-table chat-files-table--tree-flat">' + theadCompact + '<tbody>' + bodyRows + '</tbody></table></div>';
} else if (groupMode === 'none') {
const rows = chatFilesDisplayed.map(function (f, idx) {
return rowHtml(f, idx);
}).join('');
innerHtml = `<table class="chat-files-table">${theadHtml}<tbody>${rows}</tbody></table>`;
} 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 `<details class="chat-files-group" open>
<summary class="chat-files-group-summary"${summaryTitleAttr}>
<span class="chat-files-group-title">${summaryMain}</span>
<span class="chat-files-group-count">${countLabel}</span>
</summary>
<div class="chat-files-group-body">
<table class="chat-files-table">${theadHtml}<tbody>${rows}</tbody></table>
</div>
</details>`;
}).join('');
innerHtml = `<div class="chat-files-grouped">${blocks}</div>`;
}
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 });
+10 -1
View File
@@ -1013,7 +1013,7 @@
<div class="page-header">
<h2 data-i18n="chatFilesPage.title">文件管理</h2>
<div class="page-header-actions">
<button type="button" class="btn-primary" onclick="document.getElementById('chat-files-upload-input').click()" data-i18n="chatFilesPage.upload">上传文件</button>
<button type="button" class="btn-primary" onclick="chatFilesOpenUploadPicker()" data-i18n="chatFilesPage.upload">上传文件</button>
<input type="file" id="chat-files-upload-input" style="display:none" onchange="onChatFilesUploadPick(event)" />
<button class="btn-secondary" onclick="loadChatFilesPage()" data-i18n="common.refresh">刷新</button>
</div>
@@ -1029,6 +1029,15 @@
<span data-i18n="chatFilesPage.searchName">文件名</span>
<input type="text" id="chat-files-filter-name" class="form-control" data-i18n="chatFilesPage.searchNamePlaceholder" data-i18n-attr="placeholder" placeholder="筛选文件名" oninput="chatFilesFilterNameOnInput()" onkeydown="if(event.key==='Enter') loadChatFilesPage()" />
</label>
<label>
<span data-i18n="chatFilesPage.groupBy">分组</span>
<select id="chat-files-group-by" class="form-control" onchange="chatFilesGroupByChange()">
<option value="none" data-i18n="chatFilesPage.groupNone">不分组</option>
<option value="date" data-i18n="chatFilesPage.groupByDate">按日期</option>
<option value="conversation" data-i18n="chatFilesPage.groupByConversation">按会话</option>
<option value="folder" data-i18n="chatFilesPage.groupByFolder">按文件夹</option>
</select>
</label>
<button class="btn-secondary" type="button" onclick="loadChatFilesPage()" data-i18n="common.search">搜索</button>
</div>
<div id="chat-files-list-wrap" class="chat-files-table-wrap">