agent-Specialization/static/src/components/sidebar/ConversationSidebar.vue

1220 lines
48 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<aside class="conversation-sidebar" :class="{ collapsed }">
<div class="conversation-sidebar-inner">
<div class="conversation-primary-actions">
<button
type="button"
class="sidebar-nav-row conversation-menu-btn"
:title="collapsed ? '展开对话记录' : '收起对话记录'"
@click="$emit('toggle')"
>
<span
class="sidebar-nav-icon chat-icon"
data-tutorial="conversation-menu"
aria-hidden="true"
>
<svg viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M5 6.5c0-1.38 1.12-2.5 2.5-2.5h13c1.38 0 2.5 1.12 2.5 2.5v8.5c0 1.38-1.12 2.5-2.5 2.5h-5.6l-3.4 3.2.6-3.2H7.5c-1.38 0-2.5-1.12-2.5-2.5V6.5z"
stroke="currentColor"
stroke-width="1.45"
stroke-linejoin="round"
/>
<path
d="M9 9.5h10"
stroke="currentColor"
stroke-width="1.45"
stroke-linecap="round"
/>
<path d="M9 13h6" stroke="currentColor" stroke-width="1.45" stroke-linecap="round" />
</svg>
</span>
<span class="sidebar-nav-label">对话记录</span>
</button>
<button type="button" class="sidebar-nav-row" title="新建对话" @click="$emit('create')">
<span
class="sidebar-nav-icon pencil-icon"
data-tutorial="quick-new-conversation"
aria-hidden="true"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"
/>
<path d="m15 5 4 4" />
</svg>
</span>
<span class="sidebar-nav-label">新建对话</span>
</button>
<button
v-if="hostWorkspaceEnabled"
type="button"
class="sidebar-nav-row workspace-entry-btn"
:class="{ active: workspaceSwitcherOpen }"
:title="workspaceKind === 'project' ? '项目' : '工作区'"
data-tutorial="workspace-toggle"
ref="workspaceEntryBtn"
@click="toggleWorkspaceSwitcher"
>
<span class="sidebar-nav-icon layers-icon" aria-hidden="true">
<!-- 与其他导航按钮统一:内联 layers 图标CSS 限到 18px 与铅笔一致,避免 mask 图标异步加载闪烁 -->
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z"
/>
<path
d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12"
/>
<path
d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17"
/>
</svg>
</span>
<span class="sidebar-nav-label">{{ workspaceKind === 'project' ? '项目' : '工作区' }}</span>
</button>
</div>
<div class="conversation-search">
<label class="search-input-wrap">
<span class="sidebar-nav-icon search-inline-icon" aria-hidden="true">
<span class="icon icon-sm" :style="iconStyle('search')"></span>
</span>
<input
class="search-input"
:value="searchQuery"
placeholder="搜索对话"
@input="$emit('search', ($event.target as HTMLInputElement).value)"
@keydown.enter.prevent="
$emit('search-submit', ($event.target as HTMLInputElement).value)
"
/>
</label>
</div>
<div class="conversation-list">
<div v-if="runningTaskItems.length && !searchActive && !isGroupByWorkspaceActive" class="running-task-section">
<div class="running-task-section-title">运行中的任务</div>
<button
v-for="task in runningTaskItems"
:key="task.task_id"
type="button"
class="running-task-item"
:class="{ active: task.conversation_id === currentConversationId }"
@click="$emit('select-running-task', task)"
>
<span class="running-task-workspace">{{ task.workspace_label || task.workspace_id }}</span>
<span class="running-task-main">
<span class="running-task-title">{{
task.conversation_title || task.message || '运行中的对话'
}}</span>
<span
v-if="isTaskActive(task)"
class="conversation-running-loader running-task-state"
aria-label="运行中"
></span>
<span v-else class="conversation-complete-check running-task-state" aria-label="已完成"></span>
</span>
</button>
</div>
<template v-if="isGroupByWorkspaceActive && !searchActive">
<div class="workspace-groups" :class="{ 'switch-instant': workspaceSwitchInstant }">
<div
v-for="ws in sortedWorkspaces"
:key="String(ws.workspace_id || ws.label)"
class="workspace-group"
>
<div
class="workspace-group-header"
:class="{ active: String(ws.workspace_id || '') === currentWorkspaceId }"
@mouseenter="hoverWorkspaceId = String(ws.workspace_id || '')"
@mouseleave="hoverWorkspaceId = null"
>
<button
type="button"
class="workspace-group-toggle"
@click="toggleWorkspaceExpanded(String(ws.workspace_id || ''))"
>
<span
class="icon icon-sm workspace-folder-icon"
:style="iconStyle(isWorkspaceExpanded(String(ws.workspace_id || '')) ? 'folderOpen' : 'folderClosed')"
aria-hidden="true"
></span>
<span class="workspace-group-label">{{ ws.label || ws.workspace_id }}</span>
<span
v-if="String(ws.workspace_id || '') === currentWorkspaceId"
class="workspace-current-dot"
aria-label="当前工作区"
></span>
<span
v-if="
isWorkspaceRunning(String(ws.workspace_id || '')) &&
!isWorkspaceExpanded(String(ws.workspace_id || '')) &&
hoverWorkspaceId !== String(ws.workspace_id || '') &&
openWorkspaceMenuId !== String(ws.workspace_id || '')
"
class="workspace-running-loader"
aria-label="运行中"
></span>
</button>
<div
class="workspace-group-actions"
:class="{ visible: hoverWorkspaceId === String(ws.workspace_id || '') || openWorkspaceMenuId === String(ws.workspace_id || '') }"
>
<button
type="button"
class="workspace-new-btn"
title="新建对话"
aria-label="新建对话"
@click="handleCreateWorkspaceConversation(String(ws.workspace_id || ''))"
>
<span class="icon icon-sm" :style="iconStyle('pencil')" aria-hidden="true"></span>
</button>
<button
type="button"
class="workspace-more-btn"
title="更多操作"
aria-label="更多操作"
:aria-expanded="openWorkspaceMenuId === String(ws.workspace_id || '')"
@click.stop="toggleWorkspaceMenu(String(ws.workspace_id || ''))"
>
<span aria-hidden="true"></span>
</button>
<div
class="workspace-actions-menu"
:class="{ open: openWorkspaceMenuId === String(ws.workspace_id || '') }"
>
<button type="button" @click="handlePinWorkspace(String(ws.workspace_id || ''))">
<span>{{ pinnedWorkspaceIds.has(String(ws.workspace_id || '')) ? '取消置顶' : '置顶' }}</span>
</button>
<button
v-if="props.versioningHostMode"
type="button"
@click="handleRevealWorkspace(String(ws.workspace_id || ''))"
>
<span>在文件夹中打开</span>
</button>
<button type="button" @click="startRenameWorkspace(ws)">
<span>重命名</span>
</button>
</div>
</div>
</div>
<div
class="workspace-group-children"
:class="{ expanded: isWorkspaceExpanded(String(ws.workspace_id || '')) }"
>
<div class="workspace-group-children-inner">
<div
v-if="!getWorkspaceGroup(String(ws.workspace_id || ''))?.conversations?.length"
class="workspace-no-conversations"
>
暂无对话
</div>
<template v-else>
<div
class="workspace-conversations-viewport"
:style="{
'--workspace-visible-count': getWorkspaceGroup(String(ws.workspace_id || ''))?.visibleLimit || 5
}"
>
<transition-group
name="conversation-delete"
tag="div"
class="workspace-conversations-list"
:class="`animation-mode-${conversationListAnimationMode}`"
@before-leave="lockLeaveItemPosition"
>
<div
v-for="conv in getWorkspaceVisibleAndBufferConversations(String(ws.workspace_id || ''))"
:key="conv.id"
class="conversation-item workspace-conversation-item"
:class="{
active: conv.id === currentConversationId,
running: isConversationActive(conv.id) || isConversationCompleted(conv.id),
'insert-from-left': conversationInsertAnimations[conv.id] === 'create',
'insert-from-under': conversationInsertAnimations[conv.id] === 'duplicate',
'duplicate-source-mask': conversationInsertAnimations[conv.id] === 'duplicateSource'
}"
@click="handleWorkspaceConversationClick(conv.id, String(ws.workspace_id || ''))"
>
<div class="conversation-title">{{ conv.title }}</div>
<div
class="conversation-action-wrap"
:class="{ 'menu-open': openActionMenuId === conv.id }"
@click.stop
>
<span
v-if="isConversationActive(conv.id)"
class="conversation-running-loader"
aria-label="运行中"
title="运行中"
></span>
<span
v-else-if="isConversationCompleted(conv.id)"
class="conversation-complete-check"
aria-label="已完成"
title="已完成"
></span>
<button
v-else
type="button"
class="conversation-more-btn"
title="更多操作"
aria-label="更多操作"
:data-action-trigger="conv.id"
:aria-expanded="openActionMenuId === conv.id"
@click="toggleActionMenu(conv.id)"
>
<span aria-hidden="true"></span>
</button>
<div
v-if="shouldShowActionMenu(conv.id)"
class="conversation-actions-menu conversation-actions-menu--fixed"
:class="{ open: openActionMenuId === conv.id }"
:style="{
top: actionMenuPosition.top + 'px',
right: actionMenuPosition.right + 'px'
}"
>
<button
type="button"
@click="
openActionMenuId = null;
$emit('duplicate', conv.id);
"
>
<span class="icon icon-sm" :style="iconStyle('copy')" aria-hidden="true"></span>
<span>复制</span>
</button>
<button
type="button"
class="danger"
@click="
openActionMenuId = null;
$emit('delete', { id: conv.id, workspaceId: String(ws.workspace_id || '') });
"
>
<span class="icon icon-sm" :style="iconStyle('trash')" aria-hidden="true"></span>
<span>删除</span>
</button>
</div>
</div>
</div>
</transition-group>
</div>
<div
v-if="getWorkspaceHasMore(String(ws.workspace_id || ''))"
class="load-more workspace-load-more"
>
<button
class="load-more-btn"
type="button"
:disabled="getWorkspaceGroup(String(ws.workspace_id || ''))?.loadingMore"
@click="loadMoreWorkspaceConversations(String(ws.workspace_id || ''))"
>
{{ getWorkspaceGroup(String(ws.workspace_id || ''))?.loadingMore ? '载入中...' : '加载更多' }}
</button>
</div>
</template>
</div>
</div>
</div>
</div>
</template>
<template v-else-if="isGroupByWorkspaceActive && searchActive">
<div class="workspace-groups search-result-groups">
<div v-if="!searchInProgress && !sortedSearchGroups.length" class="no-conversations">
未找到匹配对话
</div>
<div
v-for="group in sortedSearchGroups"
:key="String(group.workspace_id || '')"
class="workspace-group"
>
<div class="workspace-group-header search-result-group-header">
<div class="workspace-group-toggle search-result-group-toggle">
<span
class="icon icon-sm workspace-folder-icon"
:style="iconStyle('folderClosed')"
aria-hidden="true"
></span>
<span class="workspace-group-label">{{ group.label || group.workspace_id }}</span>
</div>
</div>
<div class="workspace-group-children expanded">
<div class="workspace-group-children-inner">
<div class="workspace-conversations-list">
<div
v-for="conv in group.results"
:key="conv.id"
class="conversation-item workspace-conversation-item"
:class="{
active: conv.id === currentConversationId,
running: isConversationActive(conv.id) || isConversationCompleted(conv.id)
}"
@click="handleWorkspaceConversationClick(conv.id, String(group.workspace_id || ''))"
>
<div class="conversation-title">{{ conv.title }}</div>
<div
class="conversation-action-wrap"
:class="{ 'menu-open': openActionMenuId === conv.id }"
@click.stop
>
<span
v-if="isConversationActive(conv.id)"
class="conversation-running-loader"
aria-label="运行中"
title="运行中"
></span>
<span
v-else-if="isConversationCompleted(conv.id)"
class="conversation-complete-check"
aria-label="已完成"
title="已完成"
></span>
<button
v-else
type="button"
class="conversation-more-btn"
title="更多操作"
aria-label="更多操作"
:data-action-trigger="conv.id"
:aria-expanded="openActionMenuId === conv.id"
@click="toggleActionMenu(conv.id)"
>
<span aria-hidden="true"></span>
</button>
<div
v-if="shouldShowActionMenu(conv.id)"
class="conversation-actions-menu conversation-actions-menu--fixed"
:class="{ open: openActionMenuId === conv.id }"
:style="{
top: actionMenuPosition.top + 'px',
right: actionMenuPosition.right + 'px'
}"
>
<button
type="button"
@click="
openActionMenuId = null;
$emit('duplicate', conv.id);
"
>
<span class="icon icon-sm" :style="iconStyle('copy')" aria-hidden="true"></span>
<span>复制</span>
</button>
<button
type="button"
class="danger"
@click="
openActionMenuId = null;
$emit('delete', { id: conv.id, workspaceId: String(group.workspace_id || '') });
"
>
<span class="icon icon-sm" :style="iconStyle('trash')" aria-hidden="true"></span>
<span>删除</span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<template v-else>
<div
v-if="loading && !displayConversations.length && !searchActive && !isDeletingConversation"
class="loading-conversations"
>
正在加载...
</div>
<div v-else-if="!displayConversations.length && !loading && !isDeletingConversation" class="no-conversations">
{{ searchActive ? '未找到匹配对话' : '暂无对话记录' }}
</div>
<transition-group
v-else
name="conversation-delete"
tag="div"
class="conversation-list-items"
:class="`animation-mode-${conversationListAnimationMode}`"
>
<div
v-for="conv in displayConversations"
:key="conv.id"
class="conversation-item"
:class="{
active: conv.id === currentConversationId,
running: isConversationActive(conv.id) || isConversationCompleted(conv.id),
'insert-from-left': conversationInsertAnimations[conv.id] === 'create',
'insert-from-under': conversationInsertAnimations[conv.id] === 'duplicate',
'duplicate-source-mask': conversationInsertAnimations[conv.id] === 'duplicateSource',
deleting: pendingDeletingConversationIds.includes(conv.id)
}"
@click="
openActionMenuId = null;
$emit('select', conv.id);
"
>
<div class="conversation-title">{{ conv.title }}</div>
<div
class="conversation-action-wrap"
:class="{ 'menu-open': openActionMenuId === conv.id }"
@click.stop
>
<span
v-if="isConversationActive(conv.id)"
class="conversation-running-loader"
aria-label="运行中"
title="运行中"
></span>
<span
v-else-if="isConversationCompleted(conv.id)"
class="conversation-complete-check"
aria-label="已完成"
title="已完成"
></span>
<button
v-else
type="button"
class="conversation-more-btn"
title="更多操作"
aria-label="更多操作"
:data-action-trigger="conv.id"
:aria-expanded="openActionMenuId === conv.id"
@click="toggleActionMenu(conv.id)"
>
<span aria-hidden="true"></span>
</button>
<div
v-if="!isConversationActive(conv.id) && !isConversationCompleted(conv.id) && openActionMenuId === conv.id"
class="conversation-actions-menu conversation-actions-menu--fixed"
:class="{ open: openActionMenuId === conv.id }"
:style="{
top: actionMenuPosition.top + 'px',
right: actionMenuPosition.right + 'px'
}"
>
<button
type="button"
@click="
openActionMenuId = null;
$emit('duplicate', conv.id);
"
>
<span class="icon icon-sm" :style="iconStyle('copy')" aria-hidden="true"></span>
<span>复制</span>
</button>
<button
type="button"
class="danger"
@click="
openActionMenuId = null;
$emit('delete', conv.id);
"
>
<span class="icon icon-sm" :style="iconStyle('trash')" aria-hidden="true"></span>
<span>删除</span>
</button>
</div>
</div>
</div>
</transition-group>
</template>
<div v-if="!searchActive && !isGroupByWorkspaceActive && hasMore" class="load-more">
<button
class="load-more-btn"
type="button"
:disabled="loadingMore"
@click="$emit('load-more')"
>
{{ loadingMore ? '载入中...' : '加载更多' }}
</button>
</div>
<div v-else-if="searchActive" class="load-more search-more">
<div v-if="searchInProgress" class="search-progress">
<span class="search-spinner" aria-hidden="true">
<span class="search-spinner-orbit">
<span class="icon icon-sm search-spinner-icon" :style="iconStyle('search')"></span>
</span>
</span>
<span class="search-progress-text">搜索中...</span>
</div>
<button
v-else-if="searchMoreAvailable"
class="load-more-btn"
type="button"
@click="$emit('search-more')"
>
搜索更多
</button>
<div v-else-if="displayConversations.length" class="search-done">已全部搜索</div>
</div>
</div>
<div
v-if="renameWorkspaceId"
class="workspace-rename-overlay"
@click.self="cancelRenameWorkspace"
>
<div class="workspace-rename-card">
<div class="workspace-rename-title">
重命名{{ workspaceKind === 'workspace' ? '工作区' : '项目' }}
</div>
<input
ref="renameInput"
v-model="renameWorkspaceLabel"
type="text"
class="workspace-rename-input"
placeholder="请输入名称"
@keydown.enter.prevent="submitRenameWorkspace"
@keydown.esc.prevent="cancelRenameWorkspace"
/>
<div class="workspace-rename-actions">
<button type="button" class="workspace-rename-cancel" @click="cancelRenameWorkspace">
取消
</button>
<button type="button" class="workspace-rename-confirm" @click="submitRenameWorkspace">
确认
</button>
</div>
</div>
</div>
<WorkspaceSwitcher
:open="workspaceSwitcherOpen"
:anchor-rect="workspaceAnchorRect"
:workspaces="workspaces"
:current-workspace-id="currentWorkspaceId"
:default-workspace-id="defaultWorkspaceId"
:workspace-kind="workspaceKind"
:busy="workspaceBusy"
:create-submitting="workspaceCreateSubmitting"
:error-message="workspaceError"
@close="workspaceSwitcherOpen = false"
@switch="$emit('switch-workspace', $event)"
@create="$emit('create-workspace', $event)"
@rename="$emit('rename-workspace', $event)"
@delete="$emit('delete-workspace', $event)"
@set-default="$emit('set-default-workspace', $event)"
@reveal="$emit('reveal-workspace', $event)"
/>
<div class="conversation-personal-entry" :class="{ active: personalPageVisible }">
<button
type="button"
class="sidebar-nav-row personal-page-btn"
data-tutorial="open-personal-space"
title="个人空间"
@click="$emit('personal')"
>
<span class="sidebar-nav-icon" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"
></path>
</svg>
</span>
<span class="sidebar-nav-label personal-label">个人空间</span>
</button>
</div>
</div>
</aside>
</template>
<script setup lang="ts">
defineOptions({ name: 'ConversationSidebar' });
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { storeToRefs } from 'pinia';
import { useUiStore } from '@/stores/ui';
import { useConversationStore } from '@/stores/conversation';
import { usePersonalizationStore } from '@/stores/personalization';
import WorkspaceSwitcher from './WorkspaceSwitcher.vue';
const props = withDefaults(
defineProps<{
formatTime?: (input: unknown) => string;
iconStyle?: (key: string) => Record<string, string>;
showCollapseButton?: boolean;
collapseButtonVariant?: 'toggle' | 'close';
runningTasks?: any[];
currentWorkspaceId?: string;
defaultWorkspaceId?: string;
workspaces?: any[];
workspaceKind?: 'workspace' | 'project';
workspaceBusy?: boolean;
workspaceCreateSubmitting?: boolean;
workspaceError?: string;
hostWorkspaceEnabled?: boolean;
groupByWorkspace?: boolean;
versioningHostMode?: boolean;
/** 当前会话是否仍在运行(用于标记当前正在查看的对话也显示运行中转圈) */
currentTaskInProgress?: boolean;
currentConversationId?: string;
}>(),
{
showCollapseButton: true,
collapseButtonVariant: 'toggle',
runningTasks: () => [],
currentWorkspaceId: '',
defaultWorkspaceId: '',
workspaces: () => [],
workspaceKind: 'workspace',
workspaceBusy: false,
workspaceCreateSubmitting: false,
workspaceError: '',
hostWorkspaceEnabled: false,
groupByWorkspace: false,
versioningHostMode: false,
currentTaskInProgress: false,
currentConversationId: ''
}
);
const emit = defineEmits<{
(event: 'toggle'): void;
(event: 'create'): void;
(event: 'search', value: string): void;
(event: 'search-submit', value: string): void;
(event: 'select', id: string): void;
(event: 'select-running-task', task: any): void;
(event: 'load-more'): void;
(event: 'search-more'): void;
(event: 'personal'): void;
(event: 'delete', id: string): void;
(event: 'duplicate', id: string): void;
(event: 'switch-workspace', workspaceId: string): void;
(event: 'create-workspace', payload: { path: string; label: string }): void;
(event: 'delete-workspace', item: any): void;
(event: 'set-default-workspace', workspaceId: string): void;
(event: 'select-workspace-conversation', payload: { conversationId: string; workspaceId: string }): void;
(event: 'create-workspace-conversation', workspaceId: string): void;
(event: 'reveal-workspace', workspaceId: string): void;
(event: 'rename-workspace', payload: { workspaceId: string; label: string }): void;
(event: 'pin-workspace', workspaceId: string): void;
}>();
const uiStore = useUiStore();
const conversationStore = useConversationStore();
const personalizationStore = usePersonalizationStore();
const { sidebarCollapsed: collapsed } = storeToRefs(uiStore);
const {
searchQuery,
searchResults,
searchGroups,
searchActive,
searchInProgress,
searchMoreAvailable,
conversations,
conversationInsertAnimations,
conversationListAnimationMode,
pendingDeletingConversationIds,
conversationsLoading,
hasMoreConversations: hasMore,
loadingMoreConversations: loadingMore,
currentConversationId
} = storeToRefs(conversationStore);
const { visible: personalPageVisible } = storeToRefs(personalizationStore);
const iconStyle = (key: string) => (props.iconStyle ? props.iconStyle(key) : {});
/* ---------- 工作区切换浮层 ---------- */
const workspaceEntryBtn = ref<HTMLElement | null>(null);
const workspaceSwitcherOpen = ref(false);
const workspaceAnchorRect = ref<{ left: number; top: number; right: number; bottom: number } | null>(null);
const toggleWorkspaceSwitcher = () => {
if (workspaceSwitcherOpen.value) {
workspaceSwitcherOpen.value = false;
return;
}
const el = workspaceEntryBtn.value;
if (el) {
const rect = el.getBoundingClientRect();
/* 折叠时 sidebar-inner 保持展开宽、超出部分被 overflow 裁切,
按钮布局右缘在可视区外;锚点右缘需夹紧到侧边栏可视右缘,
否则浮层会出现在「展开后的位置」而不是紧贴折叠栏 */
const sidebarEl = el.closest('.conversation-sidebar');
const visibleRight = sidebarEl ? sidebarEl.getBoundingClientRect().right : rect.right;
workspaceAnchorRect.value = {
left: rect.left,
top: rect.top,
right: Math.min(rect.right, visibleRight),
bottom: rect.bottom
};
}
workspaceSwitcherOpen.value = true;
};
const openActionMenuId = ref<string | null>(null);
const actionMenuPosition = ref<{ top: number; right: number }>({ top: 0, right: 0 });
/* 切换工作区重排期间为 true禁用子对话 FLIP 位移与分组展开折叠过渡,
使分组头与子对话全部瞬间移动(不影响新建/复制/删除动画) */
const workspaceSwitchInstant = ref(false);
const hoverWorkspaceId = ref<string | null>(null);
const openWorkspaceMenuId = ref<string | null>(null);
const renameWorkspaceId = ref<string | null>(null);
const renameWorkspaceLabel = ref('');
const renameInput = ref<HTMLInputElement | null>(null);
watch(renameWorkspaceId, async (id) => {
if (id) {
await nextTick();
renameInput.value?.focus();
renameInput.value?.select();
}
});
const updateActionMenuPosition = (conversationId: string) => {
if (typeof document === 'undefined') return;
const trigger = document.querySelector(`[data-action-trigger="${conversationId}"]`) as HTMLElement | null;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
actionMenuPosition.value = {
top: rect.bottom + 6,
right: (typeof window !== 'undefined' ? window.innerWidth : 0) - rect.right
};
};
const toggleActionMenu = (conversationId: string) => {
if (openActionMenuId.value === conversationId) {
openActionMenuId.value = null;
} else {
updateActionMenuPosition(conversationId);
openActionMenuId.value = conversationId;
}
};
watch(openActionMenuId, async (id) => {
if (id) {
await nextTick();
updateActionMenuPosition(id);
}
});
const shouldShowActionMenu = (conversationId: string) => {
return (
openActionMenuId.value === conversationId &&
!isConversationActive(conversationId) &&
!isConversationCompleted(conversationId)
);
};
/* 删除离场动画定位修复:分组列表是 flex 容器,按 CSS Flexbox 规范,
绝对定位子元素的静态位置会跑到容器顶部(而不是元素原来的行位置),
导致删除非首条对话时离场元素瞬移到顶部。before-leave 时元素仍在文档流中,
用 offsetTop 锁定原位leave-active 的 position:absolute 生效后即停在原行。 */
const lockLeaveItemPosition = (el: Element) => {
const item = el as HTMLElement;
item.style.top = `${item.offsetTop}px`;
};
const closeActionMenu = () => {
openActionMenuId.value = null;
};
const pinnedWorkspaceIds = ref<Set<string>>(new Set());
const workspaceOrder = ref<string[]>([]);
const sidebarSortLoaded = ref(false);
const syncPinnedFromStore = () => {
pinnedWorkspaceIds.value = new Set(
Array.isArray(personalizationStore.form.sidebar_pinned_workspaces)
? personalizationStore.form.sidebar_pinned_workspaces
: []
);
};
const syncOrderFromStore = () => {
workspaceOrder.value = Array.isArray(personalizationStore.form.sidebar_workspace_order)
? [...personalizationStore.form.sidebar_workspace_order]
: [];
};
const persistPinnedWorkspaces = () => {
if (!sidebarSortLoaded.value) return;
const next = Array.from(pinnedWorkspaceIds.value);
const current = personalizationStore.form.sidebar_pinned_workspaces || [];
if (JSON.stringify(next) === JSON.stringify(current)) return;
personalizationStore.updateField({
key: 'sidebar_pinned_workspaces',
value: next
});
};
const persistWorkspaceOrder = () => {
if (!sidebarSortLoaded.value) return;
const next = Array.from(workspaceOrder.value);
const current = personalizationStore.form.sidebar_workspace_order || [];
if (JSON.stringify(next) === JSON.stringify(current)) return;
personalizationStore.updateField({
key: 'sidebar_workspace_order',
value: next
});
};
const bumpCurrentWorkspaceToTop = () => {
if (!sidebarSortLoaded.value) return;
const currentId = String(props.currentWorkspaceId || '');
if (!currentId || pinnedWorkspaceIds.value.has(currentId)) return;
if (workspaceOrder.value[0] === currentId) return;
const order = workspaceOrder.value.filter((item) => item !== currentId);
order.unshift(currentId);
workspaceOrder.value = order;
persistWorkspaceOrder();
};
const syncWorkspaceOrderWithWorkspaces = (workspaces: any[]) => {
if (!sidebarSortLoaded.value || !Array.isArray(workspaces)) return;
const ids = workspaces
.map((ws) => String(ws?.workspace_id || ''))
.filter(Boolean);
const existing = new Set(workspaceOrder.value);
const pinnedIds = new Set(
ids.filter((id) => pinnedWorkspaceIds.value.has(id))
);
const newIds = ids.filter((id) => !existing.has(id) && !pinnedIds.has(id));
const removedIds = new Set(
workspaceOrder.value.filter((id) => !ids.includes(id))
);
if (newIds.length || removedIds.size) {
workspaceOrder.value = workspaceOrder.value
.filter((id) => !removedIds.has(id))
.concat(newIds);
persistWorkspaceOrder();
}
};
const loadSidebarSortFromStore = () => {
if (!personalizationStore.loaded || sidebarSortLoaded.value) return;
syncPinnedFromStore();
syncOrderFromStore();
sidebarSortLoaded.value = true;
if (Array.isArray(props.workspaces) && props.workspaces.length > 0) {
syncWorkspaceOrderWithWorkspaces(props.workspaces);
}
bumpCurrentWorkspaceToTop();
};
watch(
() => personalizationStore.loaded,
(loaded) => {
if (loaded) loadSidebarSortFromStore();
},
{ immediate: true }
);
watch(
() => personalizationStore.form.sidebar_pinned_workspaces,
() => {
if (!sidebarSortLoaded.value) return;
syncPinnedFromStore();
},
{ deep: true }
);
watch(
() => personalizationStore.form.sidebar_workspace_order,
() => {
if (!sidebarSortLoaded.value) return;
syncOrderFromStore();
if (Array.isArray(props.workspaces) && props.workspaces.length > 0) {
syncWorkspaceOrderWithWorkspaces(props.workspaces);
}
bumpCurrentWorkspaceToTop();
},
{ deep: true }
);
watch(
() => props.workspaces,
(list) => {
if (Array.isArray(list) && list.length > 0) {
syncWorkspaceOrderWithWorkspaces(list);
}
},
{ immediate: true, deep: true }
);
const isGroupByWorkspaceActive = computed(
() => props.groupByWorkspace && props.hostWorkspaceEnabled && Array.isArray(props.workspaces) && props.workspaces.length > 0
);
/* 注意:本 watcher 声明必须放在 isGroupByWorkspaceActive 之后——
immediate 回调会在 setup 阶段同步执行,提前引用会触发 TDZ 报错。 */
watch(
() => props.currentWorkspaceId,
async () => {
/* 切换工作区会触发 bumpCurrentWorkspaceToTop 重排分组:分组头是瞬时
移动的,而 transition-group 会对子对话做 FLIP 位移动画,节奏不一致
很诡异。重排期间挂上 switch-instant 让两者都瞬间完成。 */
if (isGroupByWorkspaceActive.value) {
workspaceSwitchInstant.value = true;
bumpCurrentWorkspaceToTop();
await nextTick();
workspaceSwitchInstant.value = false;
} else {
bumpCurrentWorkspaceToTop();
}
},
{ immediate: true }
);
const sortedWorkspaces = computed(() => {
const list = Array.isArray(props.workspaces) ? [...props.workspaces] : [];
return list.sort((a: any, b: any) => {
const aId = String(a?.workspace_id || '');
const bId = String(b?.workspace_id || '');
const aPinned = pinnedWorkspaceIds.value.has(aId) ? 1 : 0;
const bPinned = pinnedWorkspaceIds.value.has(bId) ? 1 : 0;
if (aPinned !== bPinned) return bPinned - aPinned;
const aIndex = workspaceOrder.value.indexOf(aId);
const bIndex = workspaceOrder.value.indexOf(bId);
if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex;
if (aIndex !== -1) return -1;
if (bIndex !== -1) return 1;
return String(a?.label || aId).localeCompare(String(b?.label || bId));
});
});
const displayConversations = computed(() =>
searchActive.value ? searchResults.value : conversations.value
);
/* 跨工作区搜索结果:按与工作区分组列表一致的顺序排列(置顶 > 自定义顺序 > 名称) */
const sortedSearchGroups = computed(() => {
const groups = Array.isArray(searchGroups.value) ? [...searchGroups.value] : [];
return groups.sort((a: any, b: any) => {
const aId = String(a?.workspace_id || '');
const bId = String(b?.workspace_id || '');
const aPinned = pinnedWorkspaceIds.value.has(aId) ? 1 : 0;
const bPinned = pinnedWorkspaceIds.value.has(bId) ? 1 : 0;
if (aPinned !== bPinned) return bPinned - aPinned;
const aIndex = workspaceOrder.value.indexOf(aId);
const bIndex = workspaceOrder.value.indexOf(bId);
if (aIndex !== -1 && bIndex !== -1) return aIndex - bIndex;
if (aIndex !== -1) return -1;
if (bIndex !== -1) return 1;
return String(a?.label || aId).localeCompare(String(b?.label || bId));
});
});
const loading = computed(() =>
searchActive.value ? searchInProgress.value : conversationsLoading.value
);
const isDeletingConversation = computed(() => conversationListAnimationMode.value === 'delete');
const runningTaskItems = computed(() =>
Array.isArray(props.runningTasks)
? props.runningTasks.filter(
(task) =>
task &&
task.task_id &&
task.conversation_id &&
task.conversation_id !== currentConversationId.value &&
String(task.workspace_id || '') !== String(props.currentWorkspaceId || '')
)
: []
);
const activeStatuses = new Set(['pending', 'running', 'cancel_requested']);
const terminalStatuses = new Set(['succeeded', 'failed', 'canceled']);
const isTaskActive = (task: any) => activeStatuses.has(String(task?.status || ''));
const isTaskCompleted = (task: any) => terminalStatuses.has(String(task?.status || ''));
const activeConversationIds = computed(
() =>
new Set(
(Array.isArray(props.runningTasks) ? props.runningTasks : [])
.filter((task) => isTaskActive(task))
.map((task) => String(task?.conversation_id || ''))
.filter(Boolean)
)
);
const completedConversationIds = computed(
() =>
new Set(
(Array.isArray(props.runningTasks) ? props.runningTasks : [])
.filter((task) => isTaskCompleted(task))
.map((task) => String(task?.conversation_id || ''))
.filter(Boolean)
)
);
const isConversationActive = (conversationId: string) => {
if (activeConversationIds.value.has(String(conversationId || ''))) return true;
// 后端 runningTasks 库只跟踪跨工作的任务(当前会话排除了),
// 所以当前会话的运行态要单独用 currentTaskInProgress 标记。
if (props.currentTaskInProgress && String(conversationId || '') === String(props.currentConversationId || '')) {
return true;
}
return false;
};
const isConversationCompleted = (conversationId: string) =>
completedConversationIds.value.has(String(conversationId || ''));
const isWorkspaceRunning = (workspaceId: string) =>
(Array.isArray(props.runningTasks) ? props.runningTasks : []).some(
(task) => isTaskActive(task) && String(task?.workspace_id || '') === workspaceId
);
const getWorkspaceGroup = (workspaceId: string) => {
return conversationStore.workspaceGroups.find((g) => g.workspaceId === workspaceId);
};
const getWorkspaceVisibleAndBufferConversations = (workspaceId: string) => {
const group = getWorkspaceGroup(workspaceId);
if (!group) return [];
const end = group.visibleLimit + group.bufferLimit;
return group.conversations.slice(0, end);
};
const getWorkspaceHasMore = (workspaceId: string) => {
const group = getWorkspaceGroup(workspaceId);
if (!group) return false;
return group.hasMore || group.conversations.length > group.visibleLimit;
};
const isWorkspaceExpanded = (workspaceId: string) => {
const group = getWorkspaceGroup(workspaceId);
return group ? group.expanded : true;
};
const toggleWorkspaceExpanded = (workspaceId: string) => {
const group = getWorkspaceGroup(workspaceId);
if (!group) {
conversationStore.ensureWorkspaceGroup(workspaceId);
void conversationStore.loadWorkspaceConversations(workspaceId);
return;
}
conversationStore.setWorkspaceGroupExpanded(workspaceId, !group.expanded);
if (!group.expanded && group.conversations.length === 0 && !group.loading) {
void conversationStore.loadWorkspaceConversations(workspaceId);
}
};
const ensureWorkspaceGroup = (workspaceId: string) => {
const group = getWorkspaceGroup(workspaceId);
if (!group) {
conversationStore.ensureWorkspaceGroup(workspaceId);
void conversationStore.loadWorkspaceConversations(workspaceId);
} else if (group.conversations.length === 0 && !group.loading && !group.loadingMore) {
void conversationStore.loadWorkspaceConversations(workspaceId);
}
};
watch(
[isGroupByWorkspaceActive, sortedWorkspaces],
([active, list]) => {
if (active && list.length > 0) {
list.forEach((ws: any) => ensureWorkspaceGroup(String(ws?.workspace_id || '')));
}
},
{ immediate: true }
);
const handleWorkspaceConversationClick = (conversationId: string, workspaceId: string) => {
openWorkspaceMenuId.value = null;
emit('select-workspace-conversation', { conversationId, workspaceId });
};
const handleCreateWorkspaceConversation = (workspaceId: string) => {
openWorkspaceMenuId.value = null;
emit('create-workspace-conversation', workspaceId);
};
const toggleWorkspaceMenu = (workspaceId: string) => {
openWorkspaceMenuId.value = openWorkspaceMenuId.value === workspaceId ? null : workspaceId;
};
const closeWorkspaceMenu = () => {
openWorkspaceMenuId.value = null;
};
const handlePinWorkspace = (workspaceId: string) => {
openWorkspaceMenuId.value = null;
const order = [...workspaceOrder.value];
const orderIndex = order.indexOf(workspaceId);
if (pinnedWorkspaceIds.value.has(workspaceId)) {
pinnedWorkspaceIds.value.delete(workspaceId);
if (orderIndex !== -1) order.splice(orderIndex, 1);
const currentId = String(props.currentWorkspaceId || '');
const currentIndex = order.indexOf(currentId);
const insertIndex = currentIndex !== -1 ? currentIndex + 1 : 0;
order.splice(insertIndex, 0, workspaceId);
} else {
pinnedWorkspaceIds.value.add(workspaceId);
if (orderIndex !== -1) order.splice(orderIndex, 1);
}
workspaceOrder.value = order;
persistPinnedWorkspaces();
persistWorkspaceOrder();
emit('pin-workspace', workspaceId);
};
const handleRevealWorkspace = (workspaceId: string) => {
openWorkspaceMenuId.value = null;
emit('reveal-workspace', workspaceId);
};
const startRenameWorkspace = (workspace: any) => {
openWorkspaceMenuId.value = null;
renameWorkspaceId.value = String(workspace?.workspace_id || '');
renameWorkspaceLabel.value = String(workspace?.label || workspace?.workspace_id || '');
};
const submitRenameWorkspace = () => {
const workspaceId = renameWorkspaceId.value;
const label = renameWorkspaceLabel.value.trim();
if (workspaceId && label) {
emit('rename-workspace', { workspaceId, label });
}
renameWorkspaceId.value = null;
renameWorkspaceLabel.value = '';
};
const cancelRenameWorkspace = () => {
renameWorkspaceId.value = null;
renameWorkspaceLabel.value = '';
};
const loadMoreWorkspaceConversations = (workspaceId: string) => {
void conversationStore.loadMoreWorkspaceConversations(workspaceId);
};
onMounted(() => {
document.addEventListener('click', closeActionMenu);
document.addEventListener('click', closeWorkspaceMenu);
if (isGroupByWorkspaceActive.value) {
sortedWorkspaces.value.forEach((ws: any) => ensureWorkspaceGroup(String(ws?.workspace_id || '')));
}
});
onBeforeUnmount(() => {
document.removeEventListener('click', closeActionMenu);
document.removeEventListener('click', closeWorkspaceMenu);
});
</script>