feat(chat): pin, multi-select delete and agent filter for conversations (#144)

This commit is contained in:
matevip 2026-05-17 09:25:02 +08:00
parent 18df97a8e6
commit 7fb47390af
11 changed files with 427 additions and 16 deletions

View File

@ -94,6 +94,7 @@ public class ConversationService {
LambdaQueryWrapper<ConversationEntity> wrapper = new LambdaQueryWrapper<ConversationEntity>()
.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<ConversationEntity>()
.eq(ConversationEntity::getConversationId, conversationId));
if (conv != null) {
conv.setPinned(pinned ? 1 : 0);
conversationMapper.updateById(conv);
}
}
/**
* 更新会话的流状态running / idle
*/

View File

@ -125,6 +125,47 @@ public class ConversationController {
return R.ok();
}
/**
* 置顶 / 取消置顶会话
*/
@Operation(summary = "置顶或取消置顶会话")
@PutMapping("/{conversationId}/pin")
public R<Void> setPinned(@PathVariable String conversationId,
@RequestBody Map<String, Boolean> 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<Integer> batchDelete(@RequestBody Map<String, List<String>> body, Authentication auth) {
String username = auth != null ? auth.getName() : "anonymous";
List<String> 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);
}
/**
* 清空会话消息保留会话记录
*/

View File

@ -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;

View File

@ -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());
// 补充关联字段

View File

@ -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;

View File

@ -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;

View File

@ -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 ====================

View File

@ -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',

View File

@ -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: '定时任务',

View File

@ -65,6 +65,7 @@ export interface Conversation {
status?: 'active' | 'closed'
streamStatus?: 'idle' | 'running'
source?: string
pinned?: number
lastActiveTime?: string
updateTime?: string
createTime?: string

View File

@ -14,9 +14,20 @@
<div class="panel-kicker">{{ $t('nav.chat') }}</div>
<h2 class="panel-title">{{ $t('chat.conversations') }}</h2>
</div>
<button class="new-chat-btn" @click="newConversation" :title="`${$t('chat.newChat')} (⌘N)`">
<el-icon><Plus /></el-icon>
</button>
<div class="panel-header-actions">
<button
v-if="(!convPanelCollapsed || isMobile) && conversations.length > 0"
class="panel-icon-btn"
:class="{ active: selectMode }"
@click="toggleSelectMode"
:title="selectMode ? $t('chat.exitSelectMode') : $t('chat.selectMode')"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
</button>
<button class="new-chat-btn" @click="newConversation" :title="`${$t('chat.newChat')} (⌘N)`">
<el-icon><Plus /></el-icon>
</button>
</div>
</div>
<!-- 折叠切换按钮 -->
<button v-if="!isMobile" class="conv-collapse-btn" @click="toggleConvPanel" :title="convPanelCollapsed ? $t('common.expandSidebar') : $t('common.collapseSidebar')">
@ -35,6 +46,16 @@
/>
</div>
<div
v-if="(!convPanelCollapsed || isMobile) && agentFilterOptions.length > 1"
class="conv-filter"
>
<select v-model="convAgentFilter" class="conv-filter-select">
<option value="">{{ $t('chat.allAgents') }}</option>
<option v-for="opt in agentFilterOptions" :key="opt.id" :value="opt.id">{{ opt.name }}</option>
</select>
</div>
<div class="conversation-list">
<template v-for="group in groupedConversations" :key="group.label">
<div v-if="!convPanelCollapsed || isMobile" class="conv-group-title">{{ group.label }}</div>
@ -43,11 +64,23 @@
:key="conv.conversationId"
class="conv-item"
:class="{
active: currentConversationId === conv.conversationId,
active: !selectMode && currentConversationId === conv.conversationId,
'is-running': conv.streamStatus === 'running',
'is-selected': selectMode && selectedConvIds.includes(conv.conversationId),
}"
@click="selectConversation(conv)"
@click="onConvClick(conv)"
>
<label
v-if="selectMode && (!convPanelCollapsed || isMobile)"
class="conv-checkbox"
@click.stop
>
<input
type="checkbox"
:checked="selectedConvIds.includes(conv.conversationId)"
@change="toggleConvSelection(conv)"
/>
</label>
<div class="conv-icon">
<img :src="channelIconUrl(conv.source)" width="14" height="14" alt="" />
<span
@ -94,9 +127,22 @@
<span>{{ formatConversationTime(conv.lastActiveTime) }}</span>
</div>
</div>
<button v-if="!convPanelCollapsed || isMobile" class="conv-delete" @click.stop="confirmDeleteConversation(conv.conversationId)" :title="$t('common.delete')">
<el-icon><Delete /></el-icon>
</button>
<div v-if="!selectMode && (!convPanelCollapsed || isMobile)" class="conv-actions">
<button
class="conv-action"
:class="{ pinned: conv.pinned }"
@click.stop="togglePin(conv)"
:title="conv.pinned ? $t('chat.unpin') : $t('chat.pin')"
>
<svg width="13" height="13" viewBox="0 0 24 24" :fill="conv.pinned ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2"><line x1="12" y1="17" x2="12" y2="22"/><path d="M9 10.76V5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v5.76a2 2 0 0 0 .59 1.41L17 14H7l1.41-1.83A2 2 0 0 0 9 10.76z"/></svg>
</button>
<button class="conv-action" @click.stop="startRename(conv)" :title="$t('chat.rename')">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
<button class="conv-action danger" @click.stop="confirmDeleteConversation(conv.conversationId)" :title="$t('common.delete')">
<el-icon><Delete /></el-icon>
</button>
</div>
</div>
</template>
@ -104,6 +150,23 @@
<p>{{ $t('chat.noConversations') }}</p>
<p>{{ $t('chat.startNewChat') }}</p>
</div>
<div v-else-if="groupedConversations.length === 0" class="empty-convs">
<p>{{ $t('chat.noConversations') }}</p>
</div>
</div>
<div v-if="selectMode && (!convPanelCollapsed || isMobile)" class="conv-select-bar">
<button class="conv-select-all" @click="toggleSelectAll">
{{ allVisibleSelected ? $t('chat.deselectAll') : $t('chat.selectAll') }}
</button>
<span class="conv-select-count">{{ $t('chat.selectedCount', { count: selectedConvIds.length }) }}</span>
<button
class="conv-batch-delete"
:disabled="selectedConvIds.length === 0"
@click="batchDeleteSelected"
>
{{ $t('chat.batchDelete') }}
</button>
</div>
</div>
@ -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<string[]>([])
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_<wsId>)
// 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_<wsId>) 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<string, string>()
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;