mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-16 20:34:39 +08:00
feat(chat): pin, multi-select delete and agent filter for conversations (#144)
This commit is contained in:
parent
18df97a8e6
commit
7fb47390af
@ -94,6 +94,7 @@ public class ConversationService {
|
|||||||
LambdaQueryWrapper<ConversationEntity> wrapper = new LambdaQueryWrapper<ConversationEntity>()
|
LambdaQueryWrapper<ConversationEntity> wrapper = new LambdaQueryWrapper<ConversationEntity>()
|
||||||
.in(ConversationEntity::getUsername, username, SYSTEM_USER)
|
.in(ConversationEntity::getUsername, username, SYSTEM_USER)
|
||||||
.isNull(ConversationEntity::getParentConversationId)
|
.isNull(ConversationEntity::getParentConversationId)
|
||||||
|
.orderByDesc(ConversationEntity::getPinned)
|
||||||
.orderByDesc(ConversationEntity::getLastActiveTime);
|
.orderByDesc(ConversationEntity::getLastActiveTime);
|
||||||
if (workspaceId != null) {
|
if (workspaceId != null) {
|
||||||
wrapper.eq(ConversationEntity::getWorkspaceId, workspaceId);
|
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)
|
* 更新会话的流状态(running / idle)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -125,6 +125,47 @@ public class ConversationController {
|
|||||||
return R.ok();
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 清空会话消息(保留会话记录)
|
* 清空会话消息(保留会话记录)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -48,6 +48,9 @@ public class ConversationEntity {
|
|||||||
/** 父会话 ID(委派场景下,子会话记录其父会话的 conversationId) */
|
/** 父会话 ID(委派场景下,子会话记录其父会话的 conversationId) */
|
||||||
private String parentConversationId;
|
private String parentConversationId;
|
||||||
|
|
||||||
|
/** Pin flag: 0 = normal, 1 = pinned to the top of the sidebar list */
|
||||||
|
private Integer pinned;
|
||||||
|
|
||||||
@TableField(fill = FieldFill.INSERT)
|
@TableField(fill = FieldFill.INSERT)
|
||||||
private LocalDateTime createTime;
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
|||||||
@ -64,6 +64,8 @@ public class ConversationVO extends ConversationEntity {
|
|||||||
vo.setMessageCount(entity.getMessageCount());
|
vo.setMessageCount(entity.getMessageCount());
|
||||||
vo.setLastMessage(entity.getLastMessage());
|
vo.setLastMessage(entity.getLastMessage());
|
||||||
vo.setLastActiveTime(entity.getLastActiveTime());
|
vo.setLastActiveTime(entity.getLastActiveTime());
|
||||||
|
vo.setWorkspaceId(entity.getWorkspaceId());
|
||||||
|
vo.setPinned(entity.getPinned() != null ? entity.getPinned() : 0);
|
||||||
vo.setCreateTime(entity.getCreateTime());
|
vo.setCreateTime(entity.getCreateTime());
|
||||||
vo.setUpdateTime(entity.getUpdateTime());
|
vo.setUpdateTime(entity.getUpdateTime());
|
||||||
// 补充关联字段
|
// 补充关联字段
|
||||||
|
|||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -164,6 +164,10 @@ export const conversationApi = {
|
|||||||
http.delete(`/conversations/${conversationId}/messages`),
|
http.delete(`/conversations/${conversationId}/messages`),
|
||||||
rename: (conversationId: string, title: string) =>
|
rename: (conversationId: string, title: string) =>
|
||||||
http.put(`/conversations/${conversationId}/title`, { title }),
|
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 ====================
|
// ==================== Skill ====================
|
||||||
|
|||||||
@ -254,6 +254,20 @@ export default {
|
|||||||
dateLast7Days: 'Last 7 Days',
|
dateLast7Days: 'Last 7 Days',
|
||||||
dateEarlier: 'Earlier',
|
dateEarlier: 'Earlier',
|
||||||
hasUnread: 'New activity',
|
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: {
|
cronRunning: {
|
||||||
executing: 'Executing…',
|
executing: 'Executing…',
|
||||||
fallbackName: 'Scheduled task',
|
fallbackName: 'Scheduled task',
|
||||||
|
|||||||
@ -254,6 +254,20 @@ export default {
|
|||||||
dateLast7Days: '近 7 天',
|
dateLast7Days: '近 7 天',
|
||||||
dateEarlier: '更早',
|
dateEarlier: '更早',
|
||||||
hasUnread: '有新内容',
|
hasUnread: '有新内容',
|
||||||
|
filterByAgent: '按员工筛选',
|
||||||
|
allAgents: '全部员工',
|
||||||
|
pin: '置顶',
|
||||||
|
unpin: '取消置顶',
|
||||||
|
rename: '重命名',
|
||||||
|
pinFailed: '置顶操作失败',
|
||||||
|
selectMode: '多选',
|
||||||
|
exitSelectMode: '退出多选',
|
||||||
|
selectAll: '全选',
|
||||||
|
deselectAll: '取消全选',
|
||||||
|
selectedCount: '已选 {count} 项',
|
||||||
|
batchDelete: '删除选中',
|
||||||
|
batchDeleteConfirm: '确定删除选中的 {count} 个会话?此操作不可恢复。',
|
||||||
|
batchDeleteFailed: '批量删除失败',
|
||||||
cronRunning: {
|
cronRunning: {
|
||||||
executing: '执行中…',
|
executing: '执行中…',
|
||||||
fallbackName: '定时任务',
|
fallbackName: '定时任务',
|
||||||
|
|||||||
@ -65,6 +65,7 @@ export interface Conversation {
|
|||||||
status?: 'active' | 'closed'
|
status?: 'active' | 'closed'
|
||||||
streamStatus?: 'idle' | 'running'
|
streamStatus?: 'idle' | 'running'
|
||||||
source?: string
|
source?: string
|
||||||
|
pinned?: number
|
||||||
lastActiveTime?: string
|
lastActiveTime?: string
|
||||||
updateTime?: string
|
updateTime?: string
|
||||||
createTime?: string
|
createTime?: string
|
||||||
|
|||||||
@ -14,9 +14,20 @@
|
|||||||
<div class="panel-kicker">{{ $t('nav.chat') }}</div>
|
<div class="panel-kicker">{{ $t('nav.chat') }}</div>
|
||||||
<h2 class="panel-title">{{ $t('chat.conversations') }}</h2>
|
<h2 class="panel-title">{{ $t('chat.conversations') }}</h2>
|
||||||
</div>
|
</div>
|
||||||
<button class="new-chat-btn" @click="newConversation" :title="`${$t('chat.newChat')} (⌘N)`">
|
<div class="panel-header-actions">
|
||||||
<el-icon><Plus /></el-icon>
|
<button
|
||||||
</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>
|
</div>
|
||||||
<!-- 折叠切换按钮 -->
|
<!-- 折叠切换按钮 -->
|
||||||
<button v-if="!isMobile" class="conv-collapse-btn" @click="toggleConvPanel" :title="convPanelCollapsed ? $t('common.expandSidebar') : $t('common.collapseSidebar')">
|
<button v-if="!isMobile" class="conv-collapse-btn" @click="toggleConvPanel" :title="convPanelCollapsed ? $t('common.expandSidebar') : $t('common.collapseSidebar')">
|
||||||
@ -35,6 +46,16 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div class="conversation-list">
|
||||||
<template v-for="group in groupedConversations" :key="group.label">
|
<template v-for="group in groupedConversations" :key="group.label">
|
||||||
<div v-if="!convPanelCollapsed || isMobile" class="conv-group-title">{{ group.label }}</div>
|
<div v-if="!convPanelCollapsed || isMobile" class="conv-group-title">{{ group.label }}</div>
|
||||||
@ -43,11 +64,23 @@
|
|||||||
:key="conv.conversationId"
|
:key="conv.conversationId"
|
||||||
class="conv-item"
|
class="conv-item"
|
||||||
:class="{
|
:class="{
|
||||||
active: currentConversationId === conv.conversationId,
|
active: !selectMode && currentConversationId === conv.conversationId,
|
||||||
'is-running': conv.streamStatus === 'running',
|
'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">
|
<div class="conv-icon">
|
||||||
<img :src="channelIconUrl(conv.source)" width="14" height="14" alt="" />
|
<img :src="channelIconUrl(conv.source)" width="14" height="14" alt="" />
|
||||||
<span
|
<span
|
||||||
@ -94,9 +127,22 @@
|
|||||||
<span>{{ formatConversationTime(conv.lastActiveTime) }}</span>
|
<span>{{ formatConversationTime(conv.lastActiveTime) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button v-if="!convPanelCollapsed || isMobile" class="conv-delete" @click.stop="confirmDeleteConversation(conv.conversationId)" :title="$t('common.delete')">
|
<div v-if="!selectMode && (!convPanelCollapsed || isMobile)" class="conv-actions">
|
||||||
<el-icon><Delete /></el-icon>
|
<button
|
||||||
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -104,6 +150,23 @@
|
|||||||
<p>{{ $t('chat.noConversations') }}</p>
|
<p>{{ $t('chat.noConversations') }}</p>
|
||||||
<p>{{ $t('chat.startNewChat') }}</p>
|
<p>{{ $t('chat.startNewChat') }}</p>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -518,6 +581,66 @@ async function confirmDeleteConversation(conversationId: string) {
|
|||||||
if (ok) deleteConversation(conversationId)
|
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)
|
const isDragging = ref(false)
|
||||||
let dragCounter = 0
|
let dragCounter = 0
|
||||||
@ -766,10 +889,9 @@ const groupedConversations = computed(() => {
|
|||||||
const yesterdayStart = todayStart - 86400000
|
const yesterdayStart = todayStart - 86400000
|
||||||
const last7Start = todayStart - 7 * 86400000
|
const last7Start = todayStart - 7 * 86400000
|
||||||
|
|
||||||
// Pinned group always sits at the top so the unified cron output (tasks_<wsId>)
|
// Pinned group sits at the top: the unified cron output (tasks_<wsId>) plus
|
||||||
// is reachable in one glance even after a busy day pushes other conversations
|
// any conversation the user has explicitly pinned, so important threads stay
|
||||||
// ahead of it. Only the unified-cron conversation pattern is pinned for now;
|
// reachable in one glance even after a busy day pushes others ahead.
|
||||||
// we'll generalize when we have other always-visible conversations.
|
|
||||||
const pinned: Conversation[] = []
|
const pinned: Conversation[] = []
|
||||||
const groups: { label: string; items: Conversation[] }[] = [
|
const groups: { label: string; items: Conversation[] }[] = [
|
||||||
{ label: t('chat.datePinned', '置顶'), items: pinned },
|
{ label: t('chat.datePinned', '置顶'), items: pinned },
|
||||||
@ -779,8 +901,10 @@ const groupedConversations = computed(() => {
|
|||||||
{ label: t('chat.dateEarlier'), items: [] },
|
{ label: t('chat.dateEarlier'), items: [] },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const agentFilter = convAgentFilter.value
|
||||||
for (const conv of conversations.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)
|
pinned.push(conv)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@ -794,6 +918,29 @@ const groupedConversations = computed(() => {
|
|||||||
return groups.filter(g => g.items.length > 0)
|
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(() => {
|
const currentRuntimeModel = computed(() => {
|
||||||
if (defaultModel.value?.name && defaultModel.value?.modelName) {
|
if (defaultModel.value?.name && defaultModel.value?.modelName) {
|
||||||
return `${defaultModel.value.name} (${defaultModel.value.modelName})`
|
return `${defaultModel.value.name} (${defaultModel.value.modelName})`
|
||||||
@ -2188,6 +2335,7 @@ function handleCodeCopy(e: MouseEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.conv-item {
|
.conv-item {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@ -2206,7 +2354,7 @@ function handleCodeCopy(e: MouseEvent) {
|
|||||||
background: var(--mc-primary-bg);
|
background: var(--mc-primary-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.conv-item:hover .conv-delete {
|
.conv-item:hover .conv-actions {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2338,8 +2486,29 @@ function handleCodeCopy(e: MouseEvent) {
|
|||||||
box-shadow: 0 0 0 2px rgba(217, 119, 87, 0.15);
|
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;
|
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;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
border: none;
|
border: none;
|
||||||
@ -2355,11 +2524,147 @@ function handleCodeCopy(e: MouseEvent) {
|
|||||||
transition: all 0.15s;
|
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);
|
background: var(--mc-danger-bg);
|
||||||
color: var(--mc-danger);
|
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 {
|
.empty-convs {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 32px 16px;
|
padding: 32px 16px;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user