Commit 007a391e1448f7f4ec6ae78b8200158f8290591d
1 parent
66da707b
feat: conversation delete + file editor + file/folder delete in detail view
- Chat list: added 🗑 delete button per conversation (hover to reveal) - Calls DELETE /api/agent/:agentId/conversations/:convId - Backend also cleans up associated log files - Detail file tree: added 🗑 delete button per file/folder (hover to reveal) - Backend: DELETE /api/agent/:agentId/file/* with path traversal protection - Detail preview: upgraded from readonly <pre> to editable <textarea> - Backend: PUT /api/agent/:agentId/file/* for saving - Shows 'unsaved' badge + save/reset buttons when dirty - Tab key support, monospace font, auto-fill height
Showing
3 changed files
with
184 additions
and
12 deletions
server/routes/agent-chat.js
| ... | ... | @@ -807,12 +807,26 @@ function listSubAgentLogs(agentId, convId) { |
| 807 | 807 | router.delete('/:agentId/conversations/:convId', (req, res) => { |
| 808 | 808 | const { agentId, convId } = req.params; |
| 809 | 809 | try { |
| 810 | - const dir = path.join(getAgentDir(agentId), 'conversations', convId); | |
| 811 | - if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true }); | |
| 810 | + const agentDir = getAgentDir(agentId); | |
| 811 | + // 1. 删除对话目录 | |
| 812 | + const convDir = path.join(agentDir, 'conversations', convId); | |
| 813 | + if (fs.existsSync(convDir)) fs.rmSync(convDir, { recursive: true, force: true }); | |
| 814 | + // 2. 删除关联的日志目录/文件 | |
| 815 | + const logsDir = path.join(agentDir, 'logs'); | |
| 816 | + if (fs.existsSync(logsDir)) { | |
| 817 | + for (const f of fs.readdirSync(logsDir)) { | |
| 818 | + if (f.includes(convId)) { | |
| 819 | + const fp = path.join(logsDir, f); | |
| 820 | + fs.rmSync(fp, { recursive: true, force: true }); | |
| 821 | + } | |
| 822 | + } | |
| 823 | + } | |
| 824 | + // 3. 断开会话 | |
| 812 | 825 | const key = `${agentId}:${convId}`; |
| 813 | 826 | const entry = sessions.get(key); |
| 814 | 827 | if (entry && entry.protocol) entry.protocol.disconnect(); |
| 815 | 828 | sessions.delete(key); |
| 829 | + console.log(`[AgentChat] 🗑️ 已删除对话 ${agentId}/${convId}`); | |
| 816 | 830 | res.json({ success: true }); |
| 817 | 831 | } catch (err) { res.status(500).json({ error: err.message }); } |
| 818 | 832 | }); |
| ... | ... | @@ -901,6 +915,50 @@ router.get('/:agentId/file/*', (req, res) => { |
| 901 | 915 | } |
| 902 | 916 | }); |
| 903 | 917 | |
| 918 | +// PUT /api/agent/:agentId/file/* — 保存文件内容 | |
| 919 | +router.put('/:agentId/file/*', (req, res) => { | |
| 920 | + const filePath = req.params[0]; | |
| 921 | + if (!filePath) return res.status(400).json({ error: 'file path required' }); | |
| 922 | + | |
| 923 | + const agentDir = getAgentDir(req.params.agentId); | |
| 924 | + const fullPath = path.join(agentDir, filePath); | |
| 925 | + if (!fullPath.startsWith(agentDir)) { | |
| 926 | + return res.status(403).json({ error: 'Access denied' }); | |
| 927 | + } | |
| 928 | + | |
| 929 | + try { | |
| 930 | + // 确保目录存在 | |
| 931 | + const dir = path.dirname(fullPath); | |
| 932 | + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); | |
| 933 | + fs.writeFileSync(fullPath, req.body.content || '', 'utf-8'); | |
| 934 | + console.log(`[AgentChat] 💾 已保存文件 ${req.params.agentId}/${filePath}`); | |
| 935 | + res.json({ success: true, path: filePath }); | |
| 936 | + } catch (err) { | |
| 937 | + res.status(500).json({ error: err.message }); | |
| 938 | + } | |
| 939 | +}); | |
| 940 | + | |
| 941 | +// DELETE /api/agent/:agentId/file/* — 删除文件或文件夹 | |
| 942 | +router.delete('/:agentId/file/*', (req, res) => { | |
| 943 | + const filePath = req.params[0]; | |
| 944 | + if (!filePath) return res.status(400).json({ error: 'file path required' }); | |
| 945 | + | |
| 946 | + const agentDir = getAgentDir(req.params.agentId); | |
| 947 | + const fullPath = path.join(agentDir, filePath); | |
| 948 | + if (!fullPath.startsWith(agentDir)) { | |
| 949 | + return res.status(403).json({ error: 'Access denied' }); | |
| 950 | + } | |
| 951 | + if (!fs.existsSync(fullPath)) return res.status(404).json({ error: 'Not found' }); | |
| 952 | + | |
| 953 | + try { | |
| 954 | + fs.rmSync(fullPath, { recursive: true, force: true }); | |
| 955 | + console.log(`[AgentChat] 🗑️ 已删除 ${req.params.agentId}/${filePath}`); | |
| 956 | + res.json({ success: true, path: filePath }); | |
| 957 | + } catch (err) { | |
| 958 | + res.status(500).json({ error: err.message }); | |
| 959 | + } | |
| 960 | +}); | |
| 961 | + | |
| 904 | 962 | // GET/PUT /api/agent/:agentId/todo |
| 905 | 963 | router.get('/:agentId/todo', (req, res) => { |
| 906 | 964 | const fp = path.join(getAgentDir(req.params.agentId), 'todo.md'); | ... | ... |
web/src/components/FileNode.vue
| ... | ... | @@ -10,6 +10,7 @@ |
| 10 | 10 | <span class="tree-name">{{ node.name }}</span> |
| 11 | 11 | <span v-if="node.type === 'file'" class="tree-size">{{ formatSize(node.size) }}</span> |
| 12 | 12 | <span v-if="node.type === 'dir'" class="tree-count">{{ node.count }}</span> |
| 13 | + <button class="tree-delete-btn" title="删除" @click.stop="handleDelete">🗑</button> | |
| 13 | 14 | </div> |
| 14 | 15 | <template v-if="node.type === 'dir' && expanded && node.children"> |
| 15 | 16 | <FileNode |
| ... | ... | @@ -19,6 +20,7 @@ |
| 19 | 20 | :depth="depth + 1" |
| 20 | 21 | :selected="selected" |
| 21 | 22 | @select="(p) => $emit('select', p)" |
| 23 | + @delete="(p) => $emit('delete', p)" | |
| 22 | 24 | /> |
| 23 | 25 | </template> |
| 24 | 26 | </div> |
| ... | ... | @@ -33,7 +35,7 @@ const props = defineProps({ |
| 33 | 35 | selected: { type: String, default: null }, |
| 34 | 36 | }) |
| 35 | 37 | |
| 36 | -const emit = defineEmits(['select']) | |
| 38 | +const emit = defineEmits(['select', 'delete']) | |
| 37 | 39 | const expanded = ref(props.depth < 1) |
| 38 | 40 | |
| 39 | 41 | const fileIcon = computed(() => { |
| ... | ... | @@ -50,6 +52,13 @@ function handleClick() { |
| 50 | 52 | } |
| 51 | 53 | } |
| 52 | 54 | |
| 55 | +function handleDelete() { | |
| 56 | + const label = props.node.type === 'dir' ? `文件夹 "${props.node.name}" 及其所有内容` : `文件 "${props.node.name}"` | |
| 57 | + if (confirm(`确定删除${label}?此操作不可撤销。`)) { | |
| 58 | + emit('delete', props.node.path) | |
| 59 | + } | |
| 60 | +} | |
| 61 | + | |
| 53 | 62 | function formatSize(bytes) { |
| 54 | 63 | if (!bytes) return '' |
| 55 | 64 | if (bytes < 1024) return `${bytes}B` |
| ... | ... | @@ -64,6 +73,7 @@ function formatSize(bytes) { |
| 64 | 73 | transition: background 0.1s; user-select: none; |
| 65 | 74 | } |
| 66 | 75 | .tree-node:hover { background: var(--bg-surface-hover); } |
| 76 | +.tree-node:hover .tree-delete-btn { opacity: 1; } | |
| 67 | 77 | .tree-node.active { background: var(--bg-surface-active); } |
| 68 | 78 | .tree-icon { font-size: 12px; flex-shrink: 0; width: 16px; text-align: center; } |
| 69 | 79 | .tree-name { |
| ... | ... | @@ -73,4 +83,10 @@ function formatSize(bytes) { |
| 73 | 83 | .is-dir .tree-name { font-weight: 600; color: var(--text-primary); } |
| 74 | 84 | .tree-size { font-size: 9px; color: var(--text-faint); font-family: 'JetBrains Mono', monospace; } |
| 75 | 85 | .tree-count { font-size: 9px; color: var(--text-faint); } |
| 86 | +.tree-delete-btn { | |
| 87 | + opacity: 0; border: none; background: transparent; cursor: pointer; | |
| 88 | + font-size: 10px; padding: 1px 3px; border-radius: 3px; | |
| 89 | + transition: all 0.15s; flex-shrink: 0; line-height: 1; | |
| 90 | +} | |
| 91 | +.tree-delete-btn:hover { background: rgba(239,68,68,0.15); transform: scale(1.15); } | |
| 76 | 92 | </style> | ... | ... |
web/src/views/AgentChat.vue
| ... | ... | @@ -44,7 +44,10 @@ |
| 44 | 44 | :class="{ active: activeConvId === (conv.conversationId || conv.id) }" |
| 45 | 45 | @click="switchConversation(conv.conversationId || conv.id)" |
| 46 | 46 | > |
| 47 | - <div class="conv-title">{{ conv.title || '新对话' }}</div> | |
| 47 | + <div class="conv-card-top"> | |
| 48 | + <div class="conv-title">{{ conv.title || '新对话' }}</div> | |
| 49 | + <button class="conv-delete-btn" title="删除对话" @click.stop="deleteConversation(conv.conversationId || conv.id)">🗑</button> | |
| 50 | + </div> | |
| 48 | 51 | <div class="conv-meta"> |
| 49 | 52 | <span class="conv-time">{{ formatTime(conv.updatedAt) }}</span> |
| 50 | 53 | <span class="conv-count">{{ conv.messageCount || conv.stats?.messageCount || 0 }} 条</span> |
| ... | ... | @@ -207,19 +210,26 @@ |
| 207 | 210 | <div class="col-scroll" v-if="fileTree"> |
| 208 | 211 | <div class="file-tree"> |
| 209 | 212 | <template v-for="node in fileTree.tree" :key="node.path"> |
| 210 | - <FileNode :node="node" :depth="0" :selected="selectedFile" @select="previewFile" /> | |
| 213 | + <FileNode :node="node" :depth="0" :selected="selectedFile" @select="previewFile" @delete="deleteFile" /> | |
| 211 | 214 | </template> |
| 212 | 215 | </div> |
| 213 | 216 | </div> |
| 214 | 217 | <div v-else class="acp-empty">加载中...</div> |
| 215 | 218 | </section> |
| 216 | 219 | |
| 217 | - <!-- 第4列: 文件预览 --> | |
| 220 | + <!-- 第4列: 文件编辑器 --> | |
| 218 | 221 | <aside class="acp-panel" style="flex:1;min-width:300px;"> |
| 219 | - <div class="col-header">👁️ {{ selectedFile || '预览' }}</div> | |
| 220 | - <div class="col-scroll"> | |
| 221 | - <pre v-if="previewContent !== null" class="preview-code">{{ previewContent }}</pre> | |
| 222 | - <div v-else class="acp-empty">← 点击文件以预览</div> | |
| 222 | + <div class="col-header"> | |
| 223 | + <span>✏️ {{ selectedFile || '编辑器' }}</span> | |
| 224 | + <div v-if="previewContent !== null" class="editor-actions"> | |
| 225 | + <span v-if="fileDirty" class="dirty-badge">未保存</span> | |
| 226 | + <button v-if="fileDirty" class="editor-btn reset-btn" @click="resetPreview">↩ 重置</button> | |
| 227 | + <button v-if="fileDirty" class="editor-btn save-btn" @click="saveFile">💾 保存</button> | |
| 228 | + </div> | |
| 229 | + </div> | |
| 230 | + <div class="col-scroll editor-scroll"> | |
| 231 | + <textarea v-if="previewContent !== null" v-model="editContent" class="editor-textarea" spellcheck="false"></textarea> | |
| 232 | + <div v-else class="acp-empty">← 点击文件以编辑</div> | |
| 223 | 233 | </div> |
| 224 | 234 | </aside> |
| 225 | 235 | </template> |
| ... | ... | @@ -303,6 +313,8 @@ const activeConvId = ref(null) |
| 303 | 313 | const fileTree = ref(null) |
| 304 | 314 | const selectedFile = ref(null) |
| 305 | 315 | const previewContent = ref(null) |
| 316 | +const editContent = ref(null) | |
| 317 | +const fileDirty = computed(() => editContent.value !== null && previewContent.value !== null && editContent.value !== previewContent.value) | |
| 306 | 318 | |
| 307 | 319 | // ─── Load ─── |
| 308 | 320 | |
| ... | ... | @@ -407,8 +419,45 @@ async function previewFile(filePath) { |
| 407 | 419 | if (r.ok) { |
| 408 | 420 | const data = await r.json() |
| 409 | 421 | previewContent.value = data.content |
| 422 | + editContent.value = data.content | |
| 423 | + } | |
| 424 | + } catch { previewContent.value = '(加载失败)'; editContent.value = previewContent.value } | |
| 425 | +} | |
| 426 | + | |
| 427 | +function resetPreview() { | |
| 428 | + editContent.value = previewContent.value | |
| 429 | +} | |
| 430 | + | |
| 431 | +async function saveFile() { | |
| 432 | + if (!agentId.value || !selectedFile.value) return | |
| 433 | + try { | |
| 434 | + const r = await fetch(`/api/agent/${agentId.value}/file/${selectedFile.value}`, { | |
| 435 | + method: 'PUT', | |
| 436 | + headers: { 'Content-Type': 'application/json' }, | |
| 437 | + body: JSON.stringify({ content: editContent.value }), | |
| 438 | + }) | |
| 439 | + if (r.ok) { | |
| 440 | + previewContent.value = editContent.value | |
| 441 | + // Refresh file tree to update sizes | |
| 442 | + await loadFileTree() | |
| 410 | 443 | } |
| 411 | - } catch { previewContent.value = '(加载失败)' } | |
| 444 | + } catch (e) { console.error('Save file error:', e) } | |
| 445 | +} | |
| 446 | + | |
| 447 | +async function deleteFile(filePath) { | |
| 448 | + if (!agentId.value) return | |
| 449 | + try { | |
| 450 | + const r = await fetch(`/api/agent/${agentId.value}/file/${filePath}`, { method: 'DELETE' }) | |
| 451 | + if (r.ok) { | |
| 452 | + // If the deleted file was being previewed, clear the editor | |
| 453 | + if (selectedFile.value === filePath || selectedFile.value?.startsWith(filePath + '/')) { | |
| 454 | + selectedFile.value = null | |
| 455 | + previewContent.value = null | |
| 456 | + editContent.value = null | |
| 457 | + } | |
| 458 | + await loadFileTree() | |
| 459 | + } | |
| 460 | + } catch (e) { console.error('Delete file error:', e) } | |
| 412 | 461 | } |
| 413 | 462 | |
| 414 | 463 | function newConversation() { |
| ... | ... | @@ -449,6 +498,24 @@ async function switchConversation(convId) { |
| 449 | 498 | |
| 450 | 499 | function quickSend(text) { inputText.value = text; handleSend() } |
| 451 | 500 | |
| 501 | +async function deleteConversation(convId) { | |
| 502 | + if (!confirm('确定删除这个对话?对话内容和日志将被永久清除。')) return | |
| 503 | + try { | |
| 504 | + const r = await fetch(`/api/agent/${agentId.value}/conversations/${convId}`, { method: 'DELETE' }) | |
| 505 | + if (r.ok) { | |
| 506 | + conversations.value = conversations.value.filter(c => (c.conversationId || c.id) !== convId) | |
| 507 | + if (activeConvId.value === convId) { | |
| 508 | + activeConvId.value = null | |
| 509 | + messages.value = [] | |
| 510 | + streamContent.value = '' | |
| 511 | + subAgents.value = {} | |
| 512 | + subLogList.value = [] | |
| 513 | + acpLog.value = [] | |
| 514 | + } | |
| 515 | + } | |
| 516 | + } catch (e) { console.error('Delete conv error:', e) } | |
| 517 | +} | |
| 518 | + | |
| 452 | 519 | // ─── Chat (SSE) ─── |
| 453 | 520 | |
| 454 | 521 | async function handleSend() { |
| ... | ... | @@ -678,8 +745,15 @@ onMounted(async () => { |
| 678 | 745 | margin-bottom: 2px; border: 1px solid transparent; |
| 679 | 746 | } |
| 680 | 747 | .conv-card:hover { background: var(--accent-bg); } |
| 748 | +.conv-card:hover .conv-delete-btn { opacity: 1; } | |
| 681 | 749 | .conv-card.active { background: var(--accent-bg); border-color: var(--border-active); } |
| 682 | -.conv-title { font-size: 11px; font-weight: 600; color: var(--text-secondary); margin-bottom: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 750 | +.conv-card-top { display: flex; align-items: center; gap: 4px; } | |
| 751 | +.conv-title { font-size: 11px; font-weight: 600; color: var(--text-secondary); margin-bottom: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; } | |
| 752 | +.conv-delete-btn { | |
| 753 | + opacity: 0; border: none; background: transparent; cursor: pointer; font-size: 11px; | |
| 754 | + padding: 2px 4px; border-radius: 4px; transition: all 0.15s; flex-shrink: 0; line-height: 1; | |
| 755 | +} | |
| 756 | +.conv-delete-btn:hover { background: rgba(239,68,68,0.15); transform: scale(1.15); } | |
| 683 | 757 | .conv-meta { display: flex; align-items: center; gap: 6px; } |
| 684 | 758 | .conv-time { font-size: 9px; color: var(--text-faint); } |
| 685 | 759 | .conv-count { font-size: 9px; color: var(--text-faint); } |
| ... | ... | @@ -806,4 +880,28 @@ onMounted(async () => { |
| 806 | 880 | |
| 807 | 881 | /* Preview (detail mode col 4) */ |
| 808 | 882 | .preview-code { font-family: 'JetBrains Mono', 'Menlo', monospace; font-size: 11px; line-height: 1.6; color: var(--text-secondary); white-space: pre-wrap; word-break: break-all; margin: 0; padding: 4px; } |
| 883 | + | |
| 884 | +/* Editor */ | |
| 885 | +.editor-scroll { display: flex; flex-direction: column; flex: 1; padding: 0 !important; } | |
| 886 | +.editor-textarea { | |
| 887 | + flex: 1; width: 100%; border: none; outline: none; resize: none; | |
| 888 | + background: var(--bg-code); color: var(--text-secondary); | |
| 889 | + font-family: 'JetBrains Mono', monospace; font-size: 11px; | |
| 890 | + line-height: 1.6; padding: 10px; box-sizing: border-box; | |
| 891 | + tab-size: 2; | |
| 892 | +} | |
| 893 | +.editor-textarea:focus { background: var(--bg-input-focus); } | |
| 894 | +.editor-actions { display: flex; align-items: center; gap: 4px; } | |
| 895 | +.dirty-badge { | |
| 896 | + font-size: 8px; padding: 1px 5px; border-radius: 4px; | |
| 897 | + background: rgba(251,191,36,0.15); color: var(--yellow-text); font-weight: 600; | |
| 898 | +} | |
| 899 | +.editor-btn { | |
| 900 | + border: none; padding: 2px 7px; border-radius: 4px; font-size: 10px; | |
| 901 | + cursor: pointer; transition: all 0.15s; font-weight: 600; | |
| 902 | +} | |
| 903 | +.reset-btn { background: var(--bg-input); color: var(--text-muted); } | |
| 904 | +.reset-btn:hover { background: var(--bg-surface-hover); } | |
| 905 | +.save-btn { background: var(--accent); color: #fff; } | |
| 906 | +.save-btn:hover { box-shadow: 0 2px 8px rgba(16,185,129,0.3); } | |
| 809 | 907 | </style> | ... | ... |