Commit 007a391e1448f7f4ec6ae78b8200158f8290591d

Authored by yibo
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
server/routes/agent-chat.js
@@ -807,12 +807,26 @@ function listSubAgentLogs(agentId, convId) { @@ -807,12 +807,26 @@ function listSubAgentLogs(agentId, convId) {
807 router.delete('/:agentId/conversations/:convId', (req, res) => { 807 router.delete('/:agentId/conversations/:convId', (req, res) => {
808 const { agentId, convId } = req.params; 808 const { agentId, convId } = req.params;
809 try { 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 const key = `${agentId}:${convId}`; 825 const key = `${agentId}:${convId}`;
813 const entry = sessions.get(key); 826 const entry = sessions.get(key);
814 if (entry && entry.protocol) entry.protocol.disconnect(); 827 if (entry && entry.protocol) entry.protocol.disconnect();
815 sessions.delete(key); 828 sessions.delete(key);
  829 + console.log(`[AgentChat] 🗑️ 已删除对话 ${agentId}/${convId}`);
816 res.json({ success: true }); 830 res.json({ success: true });
817 } catch (err) { res.status(500).json({ error: err.message }); } 831 } catch (err) { res.status(500).json({ error: err.message }); }
818 }); 832 });
@@ -901,6 +915,50 @@ router.get(&#39;/:agentId/file/*&#39;, (req, res) =&gt; { @@ -901,6 +915,50 @@ router.get(&#39;/:agentId/file/*&#39;, (req, res) =&gt; {
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 // GET/PUT /api/agent/:agentId/todo 962 // GET/PUT /api/agent/:agentId/todo
905 router.get('/:agentId/todo', (req, res) => { 963 router.get('/:agentId/todo', (req, res) => {
906 const fp = path.join(getAgentDir(req.params.agentId), 'todo.md'); 964 const fp = path.join(getAgentDir(req.params.agentId), 'todo.md');
web/src/components/FileNode.vue
@@ -10,6 +10,7 @@ @@ -10,6 +10,7 @@
10 <span class="tree-name">{{ node.name }}</span> 10 <span class="tree-name">{{ node.name }}</span>
11 <span v-if="node.type === 'file'" class="tree-size">{{ formatSize(node.size) }}</span> 11 <span v-if="node.type === 'file'" class="tree-size">{{ formatSize(node.size) }}</span>
12 <span v-if="node.type === 'dir'" class="tree-count">{{ node.count }}</span> 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 </div> 14 </div>
14 <template v-if="node.type === 'dir' && expanded && node.children"> 15 <template v-if="node.type === 'dir' && expanded && node.children">
15 <FileNode 16 <FileNode
@@ -19,6 +20,7 @@ @@ -19,6 +20,7 @@
19 :depth="depth + 1" 20 :depth="depth + 1"
20 :selected="selected" 21 :selected="selected"
21 @select="(p) => $emit('select', p)" 22 @select="(p) => $emit('select', p)"
  23 + @delete="(p) => $emit('delete', p)"
22 /> 24 />
23 </template> 25 </template>
24 </div> 26 </div>
@@ -33,7 +35,7 @@ const props = defineProps({ @@ -33,7 +35,7 @@ const props = defineProps({
33 selected: { type: String, default: null }, 35 selected: { type: String, default: null },
34 }) 36 })
35 37
36 -const emit = defineEmits(['select']) 38 +const emit = defineEmits(['select', 'delete'])
37 const expanded = ref(props.depth < 1) 39 const expanded = ref(props.depth < 1)
38 40
39 const fileIcon = computed(() => { 41 const fileIcon = computed(() => {
@@ -50,6 +52,13 @@ function handleClick() { @@ -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 function formatSize(bytes) { 62 function formatSize(bytes) {
54 if (!bytes) return '' 63 if (!bytes) return ''
55 if (bytes < 1024) return `${bytes}B` 64 if (bytes < 1024) return `${bytes}B`
@@ -64,6 +73,7 @@ function formatSize(bytes) { @@ -64,6 +73,7 @@ function formatSize(bytes) {
64 transition: background 0.1s; user-select: none; 73 transition: background 0.1s; user-select: none;
65 } 74 }
66 .tree-node:hover { background: var(--bg-surface-hover); } 75 .tree-node:hover { background: var(--bg-surface-hover); }
  76 +.tree-node:hover .tree-delete-btn { opacity: 1; }
67 .tree-node.active { background: var(--bg-surface-active); } 77 .tree-node.active { background: var(--bg-surface-active); }
68 .tree-icon { font-size: 12px; flex-shrink: 0; width: 16px; text-align: center; } 78 .tree-icon { font-size: 12px; flex-shrink: 0; width: 16px; text-align: center; }
69 .tree-name { 79 .tree-name {
@@ -73,4 +83,10 @@ function formatSize(bytes) { @@ -73,4 +83,10 @@ function formatSize(bytes) {
73 .is-dir .tree-name { font-weight: 600; color: var(--text-primary); } 83 .is-dir .tree-name { font-weight: 600; color: var(--text-primary); }
74 .tree-size { font-size: 9px; color: var(--text-faint); font-family: 'JetBrains Mono', monospace; } 84 .tree-size { font-size: 9px; color: var(--text-faint); font-family: 'JetBrains Mono', monospace; }
75 .tree-count { font-size: 9px; color: var(--text-faint); } 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 </style> 92 </style>
web/src/views/AgentChat.vue
@@ -44,7 +44,10 @@ @@ -44,7 +44,10 @@
44 :class="{ active: activeConvId === (conv.conversationId || conv.id) }" 44 :class="{ active: activeConvId === (conv.conversationId || conv.id) }"
45 @click="switchConversation(conv.conversationId || conv.id)" 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 <div class="conv-meta"> 51 <div class="conv-meta">
49 <span class="conv-time">{{ formatTime(conv.updatedAt) }}</span> 52 <span class="conv-time">{{ formatTime(conv.updatedAt) }}</span>
50 <span class="conv-count">{{ conv.messageCount || conv.stats?.messageCount || 0 }} 条</span> 53 <span class="conv-count">{{ conv.messageCount || conv.stats?.messageCount || 0 }} 条</span>
@@ -207,19 +210,26 @@ @@ -207,19 +210,26 @@
207 <div class="col-scroll" v-if="fileTree"> 210 <div class="col-scroll" v-if="fileTree">
208 <div class="file-tree"> 211 <div class="file-tree">
209 <template v-for="node in fileTree.tree" :key="node.path"> 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 </template> 214 </template>
212 </div> 215 </div>
213 </div> 216 </div>
214 <div v-else class="acp-empty">加载中...</div> 217 <div v-else class="acp-empty">加载中...</div>
215 </section> 218 </section>
216 219
217 - <!-- 第4列: 文件预览 --> 220 + <!-- 第4列: 文件编辑器 -->
218 <aside class="acp-panel" style="flex:1;min-width:300px;"> 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 </div> 233 </div>
224 </aside> 234 </aside>
225 </template> 235 </template>
@@ -303,6 +313,8 @@ const activeConvId = ref(null) @@ -303,6 +313,8 @@ const activeConvId = ref(null)
303 const fileTree = ref(null) 313 const fileTree = ref(null)
304 const selectedFile = ref(null) 314 const selectedFile = ref(null)
305 const previewContent = ref(null) 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 // ─── Load ─── 319 // ─── Load ───
308 320
@@ -407,8 +419,45 @@ async function previewFile(filePath) { @@ -407,8 +419,45 @@ async function previewFile(filePath) {
407 if (r.ok) { 419 if (r.ok) {
408 const data = await r.json() 420 const data = await r.json()
409 previewContent.value = data.content 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 function newConversation() { 463 function newConversation() {
@@ -449,6 +498,24 @@ async function switchConversation(convId) { @@ -449,6 +498,24 @@ async function switchConversation(convId) {
449 498
450 function quickSend(text) { inputText.value = text; handleSend() } 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 // ─── Chat (SSE) ─── 519 // ─── Chat (SSE) ───
453 520
454 async function handleSend() { 521 async function handleSend() {
@@ -678,8 +745,15 @@ onMounted(async () =&gt; { @@ -678,8 +745,15 @@ onMounted(async () =&gt; {
678 margin-bottom: 2px; border: 1px solid transparent; 745 margin-bottom: 2px; border: 1px solid transparent;
679 } 746 }
680 .conv-card:hover { background: var(--accent-bg); } 747 .conv-card:hover { background: var(--accent-bg); }
  748 +.conv-card:hover .conv-delete-btn { opacity: 1; }
681 .conv-card.active { background: var(--accent-bg); border-color: var(--border-active); } 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 .conv-meta { display: flex; align-items: center; gap: 6px; } 757 .conv-meta { display: flex; align-items: center; gap: 6px; }
684 .conv-time { font-size: 9px; color: var(--text-faint); } 758 .conv-time { font-size: 9px; color: var(--text-faint); }
685 .conv-count { font-size: 9px; color: var(--text-faint); } 759 .conv-count { font-size: 9px; color: var(--text-faint); }
@@ -806,4 +880,28 @@ onMounted(async () =&gt; { @@ -806,4 +880,28 @@ onMounted(async () =&gt; {
806 880
807 /* Preview (detail mode col 4) */ 881 /* Preview (detail mode col 4) */
808 .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; } 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 </style> 907 </style>