diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java index 2883ac5f..79a831a0 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java @@ -94,6 +94,7 @@ public class ConversationService { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .in(ConversationEntity::getUsername, username, SYSTEM_USER) .isNull(ConversationEntity::getParentConversationId) + .orderByDesc(ConversationEntity::getPinned) .orderByDesc(ConversationEntity::getLastActiveTime); if (workspaceId != null) { wrapper.eq(ConversationEntity::getWorkspaceId, workspaceId); @@ -322,6 +323,19 @@ public class ConversationService { } } + /** + * Pin or unpin a conversation. Pinned conversations sort ahead of unpinned + * ones in the sidebar list regardless of last-active time. + */ + public void setPinned(String conversationId, boolean pinned) { + ConversationEntity conv = conversationMapper.selectOne(new LambdaQueryWrapper() + .eq(ConversationEntity::getConversationId, conversationId)); + if (conv != null) { + conv.setPinned(pinned ? 1 : 0); + conversationMapper.updateById(conv); + } + } + /** * 更新会话的流状态(running / idle) */ diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/controller/ConversationController.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/controller/ConversationController.java index 10d7dc63..d55d24ba 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/controller/ConversationController.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/controller/ConversationController.java @@ -125,6 +125,47 @@ public class ConversationController { return R.ok(); } + /** + * 置顶 / 取消置顶会话 + */ + @Operation(summary = "置顶或取消置顶会话") + @PutMapping("/{conversationId}/pin") + public R setPinned(@PathVariable String conversationId, + @RequestBody Map body, + Authentication auth) { + String username = auth != null ? auth.getName() : "anonymous"; + if (!conversationService.isConversationOwner(conversationId, username)) { + return R.fail("无权操作该会话"); + } + conversationService.setPinned(conversationId, Boolean.TRUE.equals(body.get("pinned"))); + return R.ok(); + } + + /** + * 批量删除会话(仅删除当前用户有权操作的会话) + */ + @Operation(summary = "批量删除会话") + @PostMapping("/batch-delete") + public R batchDelete(@RequestBody Map> body, Authentication auth) { + String username = auth != null ? auth.getName() : "anonymous"; + List ids = body.get("conversationIds"); + if (ids == null || ids.isEmpty()) { + return R.fail("未指定要删除的会话"); + } + int deleted = 0; + for (String conversationId : ids) { + if (conversationId == null || conversationId.isBlank()) { + continue; + } + if (!conversationService.isConversationOwner(conversationId, username)) { + continue; + } + conversationService.deleteConversation(conversationId); + deleted++; + } + return R.ok(deleted); + } + /** * 清空会话消息(保留会话记录) */ diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/model/ConversationEntity.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/model/ConversationEntity.java index 5b6fa1bd..92828498 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/model/ConversationEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/model/ConversationEntity.java @@ -48,6 +48,9 @@ public class ConversationEntity { /** 父会话 ID(委派场景下,子会话记录其父会话的 conversationId) */ private String parentConversationId; + /** Pin flag: 0 = normal, 1 = pinned to the top of the sidebar list */ + private Integer pinned; + @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/vo/ConversationVO.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/vo/ConversationVO.java index 338b07b8..43c881d9 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/vo/ConversationVO.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/vo/ConversationVO.java @@ -64,6 +64,8 @@ public class ConversationVO extends ConversationEntity { vo.setMessageCount(entity.getMessageCount()); vo.setLastMessage(entity.getLastMessage()); vo.setLastActiveTime(entity.getLastActiveTime()); + vo.setWorkspaceId(entity.getWorkspaceId()); + vo.setPinned(entity.getPinned() != null ? entity.getPinned() : 0); vo.setCreateTime(entity.getCreateTime()); vo.setUpdateTime(entity.getUpdateTime()); // 补充关联字段 diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V114__conversation_pinned.sql b/mateclaw-server/src/main/resources/db/migration/h2/V114__conversation_pinned.sql new file mode 100644 index 00000000..b743de0c --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V114__conversation_pinned.sql @@ -0,0 +1,5 @@ +-- Per-conversation pin flag. A pinned conversation sorts ahead of unpinned +-- ones in the sidebar list regardless of last-active time, so users can keep +-- important conversations reachable in one glance. 0 = normal, 1 = pinned. + +ALTER TABLE mate_conversation ADD COLUMN IF NOT EXISTS pinned INT DEFAULT 0; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V114__conversation_pinned.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V114__conversation_pinned.sql new file mode 100644 index 00000000..2447467d --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V114__conversation_pinned.sql @@ -0,0 +1,8 @@ +-- Per-conversation pin flag. See the h2 sibling for the rationale. +-- MySQL has no ADD COLUMN IF NOT EXISTS; guard via INFORMATION_SCHEMA. + +SET @c := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'mate_conversation' + AND COLUMN_NAME = 'pinned'); +SET @s := IF(@c = 0, 'ALTER TABLE mate_conversation ADD COLUMN pinned INT DEFAULT 0', 'SELECT 1'); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index 127233f6..514c0b46 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -164,6 +164,10 @@ export const conversationApi = { http.delete(`/conversations/${conversationId}/messages`), rename: (conversationId: string, title: string) => http.put(`/conversations/${conversationId}/title`, { title }), + setPinned: (conversationId: string, pinned: boolean) => + http.put(`/conversations/${conversationId}/pin`, { pinned }), + batchDelete: (conversationIds: string[]) => + http.post('/conversations/batch-delete', { conversationIds }), } // ==================== Skill ==================== diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index ec5afca9..8ccf18b6 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -254,6 +254,20 @@ export default { dateLast7Days: 'Last 7 Days', dateEarlier: 'Earlier', hasUnread: 'New activity', + filterByAgent: 'Filter by employee', + allAgents: 'All employees', + pin: 'Pin', + unpin: 'Unpin', + rename: 'Rename', + pinFailed: 'Failed to update pin', + selectMode: 'Select', + exitSelectMode: 'Exit selection', + selectAll: 'Select all', + deselectAll: 'Deselect all', + selectedCount: '{count} selected', + batchDelete: 'Delete selected', + batchDeleteConfirm: 'Delete the {count} selected conversations? This cannot be undone.', + batchDeleteFailed: 'Batch delete failed', cronRunning: { executing: 'Executing…', fallbackName: 'Scheduled task', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 76a4bcfb..691c03db 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -254,6 +254,20 @@ export default { dateLast7Days: '近 7 天', dateEarlier: '更早', hasUnread: '有新内容', + filterByAgent: '按员工筛选', + allAgents: '全部员工', + pin: '置顶', + unpin: '取消置顶', + rename: '重命名', + pinFailed: '置顶操作失败', + selectMode: '多选', + exitSelectMode: '退出多选', + selectAll: '全选', + deselectAll: '取消全选', + selectedCount: '已选 {count} 项', + batchDelete: '删除选中', + batchDeleteConfirm: '确定删除选中的 {count} 个会话?此操作不可恢复。', + batchDeleteFailed: '批量删除失败', cronRunning: { executing: '执行中…', fallbackName: '定时任务', diff --git a/mateclaw-ui/src/types/index.ts b/mateclaw-ui/src/types/index.ts index 4bf8faaa..1ba25753 100644 --- a/mateclaw-ui/src/types/index.ts +++ b/mateclaw-ui/src/types/index.ts @@ -65,6 +65,7 @@ export interface Conversation { status?: 'active' | 'closed' streamStatus?: 'idle' | 'running' source?: string + pinned?: number lastActiveTime?: string updateTime?: string createTime?: string diff --git a/mateclaw-ui/src/views/ChatConsole.vue b/mateclaw-ui/src/views/ChatConsole.vue index 28c0d7ac..a0162dc0 100644 --- a/mateclaw-ui/src/views/ChatConsole.vue +++ b/mateclaw-ui/src/views/ChatConsole.vue @@ -14,9 +14,20 @@
{{ $t('nav.chat') }}

{{ $t('chat.conversations') }}

- +
+ + +
+ {{ $t('chat.selectedCount', { count: selectedConvIds.length }) }} + @@ -518,6 +581,66 @@ async function confirmDeleteConversation(conversationId: string) { if (ok) deleteConversation(conversationId) } +// Filter the sidebar list down to a single agent's conversations. +const convAgentFilter = ref('') + +// Multi-select mode for batch deletion. +const selectMode = ref(false) +const selectedConvIds = ref([]) + +function toggleSelectMode() { + selectMode.value = !selectMode.value + selectedConvIds.value = [] +} + +function toggleConvSelection(conv: Conversation) { + const id = conv.conversationId + const idx = selectedConvIds.value.indexOf(id) + if (idx >= 0) selectedConvIds.value.splice(idx, 1) + else selectedConvIds.value.push(id) +} + +function onConvClick(conv: Conversation) { + if (selectMode.value) toggleConvSelection(conv) + else selectConversation(conv) +} + +async function togglePin(conv: Conversation) { + const next = !conv.pinned + conv.pinned = next ? 1 : 0 + try { + await conversationApi.setPinned(conv.conversationId, next) + await loadConversations() + } catch { + conv.pinned = next ? 0 : 1 + mcToast.error(t('chat.pinFailed')) + } +} + +async function batchDeleteSelected() { + const ids = [...selectedConvIds.value] + if (ids.length === 0) return + const ok = await mcConfirm({ + title: t('common.confirm'), + message: t('chat.batchDeleteConfirm', { count: ids.length }), + tone: 'danger', + }) + if (!ok) return + try { + await conversationApi.batchDelete(ids) + conversations.value = conversations.value.filter(c => !ids.includes(c.conversationId)) + if (ids.includes(currentConversationId.value)) { + resetStreamingState() + messages.value = [] + currentConversationId.value = '' + } + selectedConvIds.value = [] + selectMode.value = false + } catch { + mcToast.error(t('chat.batchDeleteFailed')) + } +} + // 拖拽上传 const isDragging = ref(false) let dragCounter = 0 @@ -766,10 +889,9 @@ const groupedConversations = computed(() => { const yesterdayStart = todayStart - 86400000 const last7Start = todayStart - 7 * 86400000 - // Pinned group always sits at the top so the unified cron output (tasks_) - // is reachable in one glance even after a busy day pushes other conversations - // ahead of it. Only the unified-cron conversation pattern is pinned for now; - // we'll generalize when we have other always-visible conversations. + // Pinned group sits at the top: the unified cron output (tasks_) plus + // any conversation the user has explicitly pinned, so important threads stay + // reachable in one glance even after a busy day pushes others ahead. const pinned: Conversation[] = [] const groups: { label: string; items: Conversation[] }[] = [ { label: t('chat.datePinned', '置顶'), items: pinned }, @@ -779,8 +901,10 @@ const groupedConversations = computed(() => { { label: t('chat.dateEarlier'), items: [] }, ] + const agentFilter = convAgentFilter.value for (const conv of conversations.value) { - if (conv.conversationId && conv.conversationId.startsWith('tasks_')) { + if (agentFilter && String(conv.agentId ?? '') !== agentFilter) continue + if ((conv.conversationId && conv.conversationId.startsWith('tasks_')) || conv.pinned) { pinned.push(conv) continue } @@ -794,6 +918,29 @@ const groupedConversations = computed(() => { return groups.filter(g => g.items.length > 0) }) +// Distinct agents present in the conversation list — drives the agent filter +// dropdown. Hidden when fewer than two agents have conversations. +const agentFilterOptions = computed(() => { + const seen = new Map() + for (const conv of conversations.value) { + if (conv.agentId == null || conv.agentId === '') continue + const id = String(conv.agentId) + if (!seen.has(id)) seen.set(id, conv.agentName || id) + } + return [...seen.entries()].map(([id, name]) => ({ id, name })) +}) + +const visibleConvIds = computed(() => + groupedConversations.value.flatMap(g => g.items.map(i => i.conversationId))) + +const allVisibleSelected = computed(() => + visibleConvIds.value.length > 0 && + visibleConvIds.value.every(id => selectedConvIds.value.includes(id))) + +function toggleSelectAll() { + selectedConvIds.value = allVisibleSelected.value ? [] : [...visibleConvIds.value] +} + const currentRuntimeModel = computed(() => { if (defaultModel.value?.name && defaultModel.value?.modelName) { return `${defaultModel.value.name} (${defaultModel.value.modelName})` @@ -2188,6 +2335,7 @@ function handleCodeCopy(e: MouseEvent) { } .conv-item { + position: relative; display: flex; align-items: center; gap: 8px; @@ -2206,7 +2354,7 @@ function handleCodeCopy(e: MouseEvent) { background: var(--mc-primary-bg); } -.conv-item:hover .conv-delete { +.conv-item:hover .conv-actions { opacity: 1; } @@ -2338,8 +2486,29 @@ function handleCodeCopy(e: MouseEvent) { box-shadow: 0 0 0 2px rgba(217, 119, 87, 0.15); } -.conv-delete { +/* Actions overlay the right edge of the row so they reserve no layout width — + the conversation title keeps its full space. A left-fading gradient masks + the meta text behind them when the row is hovered. */ +.conv-actions { + position: absolute; + right: 6px; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + gap: 1px; + padding-left: 16px; opacity: 0; + background: linear-gradient(to right, transparent, var(--mc-bg-sunken) 42%); + border-radius: 12px; + transition: opacity 0.15s; +} + +.conv-item.active .conv-actions { + background: linear-gradient(to right, transparent, var(--mc-primary-bg) 42%); +} + +.conv-action { width: 22px; height: 22px; border: none; @@ -2355,11 +2524,147 @@ function handleCodeCopy(e: MouseEvent) { transition: all 0.15s; } -.conv-delete:hover { +.conv-action:hover { + background: var(--mc-bg-elevated); + color: var(--mc-text-primary); +} + +.conv-action.danger:hover { background: var(--mc-danger-bg); color: var(--mc-danger); } +/* The pin button shows the accent color while a conversation is pinned, so the + filled pin icon reads as "active" once the row is hovered. */ +.conv-action.pinned { + color: var(--mc-accent); +} + +/* Agent filter dropdown above the conversation list. */ +.conv-filter { + padding: 8px 12px 0; +} + +.conv-filter-select { + width: 100%; + font-size: 12px; + color: var(--mc-text-secondary); + background: var(--mc-bg-elevated); + border: 1px solid var(--mc-border); + border-radius: 8px; + padding: 6px 8px; + cursor: pointer; + outline: none; +} + +.conv-filter-select:focus { + border-color: var(--mc-primary); +} + +/* Multi-select mode. */ +.panel-header-actions { + display: flex; + align-items: center; + gap: 6px; +} + +.panel-icon-btn { + width: 28px; + height: 28px; + border: 1px solid var(--mc-border); + background: var(--mc-panel-raised); + border-radius: 10px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: var(--mc-text-secondary); + transition: all 0.15s; +} + +.panel-icon-btn:hover { + color: var(--mc-text-primary); + border-color: var(--mc-text-tertiary); +} + +.panel-icon-btn.active { + background: var(--mc-primary); + border-color: var(--mc-primary); + color: white; +} + +.conv-checkbox { + display: flex; + align-items: center; + flex-shrink: 0; + cursor: pointer; +} + +.conv-checkbox input { + width: 15px; + height: 15px; + cursor: pointer; + accent-color: var(--mc-primary); +} + +.conv-item.is-selected { + background: var(--mc-primary-bg); +} + +.conv-select-bar { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + border-top: 1px solid var(--mc-border-light); +} + +.conv-select-all { + font-size: 12px; + color: var(--mc-text-secondary); + background: none; + border: none; + cursor: pointer; + padding: 4px 6px; + border-radius: 6px; + white-space: nowrap; +} + +.conv-select-all:hover { + background: var(--mc-bg-sunken); + color: var(--mc-text-primary); +} + +.conv-select-count { + flex: 1; + font-size: 12px; + color: var(--mc-text-tertiary); + text-align: center; + white-space: nowrap; +} + +.conv-batch-delete { + font-size: 12px; + font-weight: 600; + color: white; + background: var(--mc-danger); + border: none; + border-radius: 8px; + padding: 6px 12px; + cursor: pointer; + white-space: nowrap; + transition: opacity 0.15s; +} + +.conv-batch-delete:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.conv-batch-delete:not(:disabled):hover { + opacity: 0.88; +} + .empty-convs { text-align: center; padding: 32px 16px;