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 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(&#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 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 () =&gt; {
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 () =&gt; {
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>
... ...