From 007a391e1448f7f4ec6ae78b8200158f8290591d Mon Sep 17 00:00:00 2001 From: yibo Date: Sat, 28 Mar 2026 00:27:12 +0800 Subject: [PATCH] feat: conversation delete + file editor + file/folder delete in detail view --- server/routes/agent-chat.js | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- web/src/components/FileNode.vue | 18 +++++++++++++++++- web/src/views/AgentChat.vue | 116 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------- 3 files changed, 184 insertions(+), 12 deletions(-) diff --git a/server/routes/agent-chat.js b/server/routes/agent-chat.js index a34e0bf..295a46b 100644 --- a/server/routes/agent-chat.js +++ b/server/routes/agent-chat.js @@ -807,12 +807,26 @@ function listSubAgentLogs(agentId, convId) { router.delete('/:agentId/conversations/:convId', (req, res) => { const { agentId, convId } = req.params; try { - const dir = path.join(getAgentDir(agentId), 'conversations', convId); - if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true, force: true }); + const agentDir = getAgentDir(agentId); + // 1. 删除对话目录 + const convDir = path.join(agentDir, 'conversations', convId); + if (fs.existsSync(convDir)) fs.rmSync(convDir, { recursive: true, force: true }); + // 2. 删除关联的日志目录/文件 + const logsDir = path.join(agentDir, 'logs'); + if (fs.existsSync(logsDir)) { + for (const f of fs.readdirSync(logsDir)) { + if (f.includes(convId)) { + const fp = path.join(logsDir, f); + fs.rmSync(fp, { recursive: true, force: true }); + } + } + } + // 3. 断开会话 const key = `${agentId}:${convId}`; const entry = sessions.get(key); if (entry && entry.protocol) entry.protocol.disconnect(); sessions.delete(key); + console.log(`[AgentChat] 🗑️ 已删除对话 ${agentId}/${convId}`); res.json({ success: true }); } catch (err) { res.status(500).json({ error: err.message }); } }); @@ -901,6 +915,50 @@ router.get('/:agentId/file/*', (req, res) => { } }); +// PUT /api/agent/:agentId/file/* — 保存文件内容 +router.put('/:agentId/file/*', (req, res) => { + const filePath = req.params[0]; + if (!filePath) return res.status(400).json({ error: 'file path required' }); + + const agentDir = getAgentDir(req.params.agentId); + const fullPath = path.join(agentDir, filePath); + if (!fullPath.startsWith(agentDir)) { + return res.status(403).json({ error: 'Access denied' }); + } + + try { + // 确保目录存在 + const dir = path.dirname(fullPath); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(fullPath, req.body.content || '', 'utf-8'); + console.log(`[AgentChat] 💾 已保存文件 ${req.params.agentId}/${filePath}`); + res.json({ success: true, path: filePath }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// DELETE /api/agent/:agentId/file/* — 删除文件或文件夹 +router.delete('/:agentId/file/*', (req, res) => { + const filePath = req.params[0]; + if (!filePath) return res.status(400).json({ error: 'file path required' }); + + const agentDir = getAgentDir(req.params.agentId); + const fullPath = path.join(agentDir, filePath); + if (!fullPath.startsWith(agentDir)) { + return res.status(403).json({ error: 'Access denied' }); + } + if (!fs.existsSync(fullPath)) return res.status(404).json({ error: 'Not found' }); + + try { + fs.rmSync(fullPath, { recursive: true, force: true }); + console.log(`[AgentChat] 🗑️ 已删除 ${req.params.agentId}/${filePath}`); + res.json({ success: true, path: filePath }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + // GET/PUT /api/agent/:agentId/todo router.get('/:agentId/todo', (req, res) => { const fp = path.join(getAgentDir(req.params.agentId), 'todo.md'); diff --git a/web/src/components/FileNode.vue b/web/src/components/FileNode.vue index 68ec1b5..4106faf 100644 --- a/web/src/components/FileNode.vue +++ b/web/src/components/FileNode.vue @@ -10,6 +10,7 @@ {{ node.name }} {{ formatSize(node.size) }} {{ node.count }} + @@ -33,7 +35,7 @@ const props = defineProps({ selected: { type: String, default: null }, }) -const emit = defineEmits(['select']) +const emit = defineEmits(['select', 'delete']) const expanded = ref(props.depth < 1) const fileIcon = computed(() => { @@ -50,6 +52,13 @@ function handleClick() { } } +function handleDelete() { + const label = props.node.type === 'dir' ? `文件夹 "${props.node.name}" 及其所有内容` : `文件 "${props.node.name}"` + if (confirm(`确定删除${label}?此操作不可撤销。`)) { + emit('delete', props.node.path) + } +} + function formatSize(bytes) { if (!bytes) return '' if (bytes < 1024) return `${bytes}B` @@ -64,6 +73,7 @@ function formatSize(bytes) { transition: background 0.1s; user-select: none; } .tree-node:hover { background: var(--bg-surface-hover); } +.tree-node:hover .tree-delete-btn { opacity: 1; } .tree-node.active { background: var(--bg-surface-active); } .tree-icon { font-size: 12px; flex-shrink: 0; width: 16px; text-align: center; } .tree-name { @@ -73,4 +83,10 @@ function formatSize(bytes) { .is-dir .tree-name { font-weight: 600; color: var(--text-primary); } .tree-size { font-size: 9px; color: var(--text-faint); font-family: 'JetBrains Mono', monospace; } .tree-count { font-size: 9px; color: var(--text-faint); } +.tree-delete-btn { + opacity: 0; border: none; background: transparent; cursor: pointer; + font-size: 10px; padding: 1px 3px; border-radius: 3px; + transition: all 0.15s; flex-shrink: 0; line-height: 1; +} +.tree-delete-btn:hover { background: rgba(239,68,68,0.15); transform: scale(1.15); } diff --git a/web/src/views/AgentChat.vue b/web/src/views/AgentChat.vue index 32ed749..773824e 100644 --- a/web/src/views/AgentChat.vue +++ b/web/src/views/AgentChat.vue @@ -44,7 +44,10 @@ :class="{ active: activeConvId === (conv.conversationId || conv.id) }" @click="switchConversation(conv.conversationId || conv.id)" > -
{{ conv.title || '新对话' }}
+
+
{{ conv.title || '新对话' }}
+ +
{{ formatTime(conv.updatedAt) }} {{ conv.messageCount || conv.stats?.messageCount || 0 }} 条 @@ -207,19 +210,26 @@
加载中...
- + @@ -303,6 +313,8 @@ const activeConvId = ref(null) const fileTree = ref(null) const selectedFile = ref(null) const previewContent = ref(null) +const editContent = ref(null) +const fileDirty = computed(() => editContent.value !== null && previewContent.value !== null && editContent.value !== previewContent.value) // ─── Load ─── @@ -407,8 +419,45 @@ async function previewFile(filePath) { if (r.ok) { const data = await r.json() previewContent.value = data.content + editContent.value = data.content + } + } catch { previewContent.value = '(加载失败)'; editContent.value = previewContent.value } +} + +function resetPreview() { + editContent.value = previewContent.value +} + +async function saveFile() { + if (!agentId.value || !selectedFile.value) return + try { + const r = await fetch(`/api/agent/${agentId.value}/file/${selectedFile.value}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content: editContent.value }), + }) + if (r.ok) { + previewContent.value = editContent.value + // Refresh file tree to update sizes + await loadFileTree() } - } catch { previewContent.value = '(加载失败)' } + } catch (e) { console.error('Save file error:', e) } +} + +async function deleteFile(filePath) { + if (!agentId.value) return + try { + const r = await fetch(`/api/agent/${agentId.value}/file/${filePath}`, { method: 'DELETE' }) + if (r.ok) { + // If the deleted file was being previewed, clear the editor + if (selectedFile.value === filePath || selectedFile.value?.startsWith(filePath + '/')) { + selectedFile.value = null + previewContent.value = null + editContent.value = null + } + await loadFileTree() + } + } catch (e) { console.error('Delete file error:', e) } } function newConversation() { @@ -449,6 +498,24 @@ async function switchConversation(convId) { function quickSend(text) { inputText.value = text; handleSend() } +async function deleteConversation(convId) { + if (!confirm('确定删除这个对话?对话内容和日志将被永久清除。')) return + try { + const r = await fetch(`/api/agent/${agentId.value}/conversations/${convId}`, { method: 'DELETE' }) + if (r.ok) { + conversations.value = conversations.value.filter(c => (c.conversationId || c.id) !== convId) + if (activeConvId.value === convId) { + activeConvId.value = null + messages.value = [] + streamContent.value = '' + subAgents.value = {} + subLogList.value = [] + acpLog.value = [] + } + } + } catch (e) { console.error('Delete conv error:', e) } +} + // ─── Chat (SSE) ─── async function handleSend() { @@ -678,8 +745,15 @@ onMounted(async () => { margin-bottom: 2px; border: 1px solid transparent; } .conv-card:hover { background: var(--accent-bg); } +.conv-card:hover .conv-delete-btn { opacity: 1; } .conv-card.active { background: var(--accent-bg); border-color: var(--border-active); } -.conv-title { font-size: 11px; font-weight: 600; color: var(--text-secondary); margin-bottom: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.conv-card-top { display: flex; align-items: center; gap: 4px; } +.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; } +.conv-delete-btn { + opacity: 0; border: none; background: transparent; cursor: pointer; font-size: 11px; + padding: 2px 4px; border-radius: 4px; transition: all 0.15s; flex-shrink: 0; line-height: 1; +} +.conv-delete-btn:hover { background: rgba(239,68,68,0.15); transform: scale(1.15); } .conv-meta { display: flex; align-items: center; gap: 6px; } .conv-time { font-size: 9px; color: var(--text-faint); } .conv-count { font-size: 9px; color: var(--text-faint); } @@ -806,4 +880,28 @@ onMounted(async () => { /* Preview (detail mode col 4) */ .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; } + +/* Editor */ +.editor-scroll { display: flex; flex-direction: column; flex: 1; padding: 0 !important; } +.editor-textarea { + flex: 1; width: 100%; border: none; outline: none; resize: none; + background: var(--bg-code); color: var(--text-secondary); + font-family: 'JetBrains Mono', monospace; font-size: 11px; + line-height: 1.6; padding: 10px; box-sizing: border-box; + tab-size: 2; +} +.editor-textarea:focus { background: var(--bg-input-focus); } +.editor-actions { display: flex; align-items: center; gap: 4px; } +.dirty-badge { + font-size: 8px; padding: 1px 5px; border-radius: 4px; + background: rgba(251,191,36,0.15); color: var(--yellow-text); font-weight: 600; +} +.editor-btn { + border: none; padding: 2px 7px; border-radius: 4px; font-size: 10px; + cursor: pointer; transition: all 0.15s; font-weight: 600; +} +.reset-btn { background: var(--bg-input); color: var(--text-muted); } +.reset-btn:hover { background: var(--bg-surface-hover); } +.save-btn { background: var(--accent); color: #fff; } +.save-btn:hover { box-shadow: 0 2px 8px rgba(16,185,129,0.3); } -- libgit2 0.21.4