Implement host-mode multi-workspace concurrency while preserving one active task per workspace. Host terminals are scoped by workspace id, host workspace switching no longer globally refreshes or blocks on tasks, and task APIs now expose workspace/conversation metadata for frontend coordination. Update conversation navigation so creating/loading conversations during an active workspace task is view-only and does not mutate backend terminal context. This prevents running task output from being pushed into the currently viewed conversation after switching or creating a new conversation. Add running task awareness to the frontend sidebar, composer, and polling flow. Running tasks can be shown across workspaces, current-workspace active conversations render inline loaders, completed unviewed tasks persist as check indicators across refresh, and completed tasks clear once viewed. Restore running conversations correctly after switching workspace/conversation or refreshing during an active task. REST polling is rebound only for the matching conversation/task, stale poll responses are ignored, empty assistant placeholders are restored before first content, and completion clears loader/stop states without requiring page refresh. Adjust host workspace UX so switching workspaces loads the active conversation if one is running, otherwise opens the new-conversation state. Other conversations in a busy workspace are view-only with disabled input instead of blocked navigation. Validation: python3 -m py_compile server/auth.py server/context.py server/conversation.py server/status.py server/tasks.py; python3 -m unittest test.test_server_refactor_smoke; npm run build --silent 2>&1 | tail -n 12.
417 lines
15 KiB
Vue
417 lines
15 KiB
Vue
<template>
|
|
<aside class="conversation-sidebar" :class="{ collapsed, 'monitor-mode': monitorModeActive }">
|
|
<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
|
|
type="button"
|
|
class="sidebar-nav-row workspace-control-btn"
|
|
:class="{ active: workspaceCollapsed }"
|
|
:title="workspaceCollapsed ? '显示工作区' : '隐藏工作区'"
|
|
@click="$emit('toggle-workspace')"
|
|
>
|
|
<span class="sidebar-nav-icon" data-tutorial="workspace-toggle" aria-hidden="true">
|
|
<span class="icon icon-md" :style="iconStyle('layers')"></span>
|
|
</span>
|
|
<span class="sidebar-nav-label">工作区</span>
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
class="sidebar-nav-row monitor-mode-btn"
|
|
:class="{ active: displayMode === 'monitor', blocked: displayModeDisabled }"
|
|
:aria-disabled="displayModeDisabled"
|
|
:title="displayMode === 'monitor' ? '退出显示器' : '虚拟显示器'"
|
|
@click="$emit('toggle-display-mode')"
|
|
>
|
|
<span class="sidebar-nav-icon" data-tutorial="monitor-toggle" aria-hidden="true">
|
|
<span class="icon icon-md" :style="iconStyle('monitor')"></span>
|
|
</span>
|
|
<span class="sidebar-nav-label">虚拟显示器</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" 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>
|
|
<div
|
|
v-if="loading && !displayConversations.length && !searchActive"
|
|
class="loading-conversations"
|
|
>
|
|
正在加载...
|
|
</div>
|
|
<div v-else-if="!displayConversations.length && !loading" 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'
|
|
}"
|
|
@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="更多操作"
|
|
:aria-expanded="openActionMenuId === conv.id"
|
|
@click="toggleActionMenu(conv.id)"
|
|
>
|
|
<span aria-hidden="true"></span>
|
|
</button>
|
|
<div
|
|
v-if="!isConversationActive(conv.id) && !isConversationCompleted(conv.id)"
|
|
class="conversation-actions-menu"
|
|
:class="{ open: openActionMenuId === conv.id }"
|
|
>
|
|
<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>
|
|
<div v-if="!searchActive && 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 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, onBeforeUnmount, onMounted, ref } from 'vue';
|
|
import { storeToRefs } from 'pinia';
|
|
import { useUiStore } from '@/stores/ui';
|
|
import { useConversationStore } from '@/stores/conversation';
|
|
import { usePersonalizationStore } from '@/stores/personalization';
|
|
|
|
const props = withDefaults(
|
|
defineProps<{
|
|
formatTime?: (input: unknown) => string;
|
|
iconStyle?: (key: string) => Record<string, string>;
|
|
showCollapseButton?: boolean;
|
|
collapseButtonVariant?: 'toggle' | 'close';
|
|
displayMode?: 'chat' | 'monitor';
|
|
displayModeDisabled?: boolean;
|
|
runningTasks?: any[];
|
|
currentWorkspaceId?: string;
|
|
}>(),
|
|
{
|
|
showCollapseButton: true,
|
|
collapseButtonVariant: 'toggle',
|
|
displayMode: 'chat',
|
|
displayModeDisabled: false,
|
|
runningTasks: () => [],
|
|
currentWorkspaceId: ''
|
|
}
|
|
);
|
|
|
|
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: 'toggle-workspace'): void;
|
|
(event: 'toggle-display-mode'): void;
|
|
}>();
|
|
|
|
const uiStore = useUiStore();
|
|
const conversationStore = useConversationStore();
|
|
const personalizationStore = usePersonalizationStore();
|
|
|
|
const {
|
|
sidebarCollapsed: collapsed,
|
|
workspaceCollapsed,
|
|
chatDisplayMode: uiChatDisplayMode
|
|
} = storeToRefs(uiStore);
|
|
const {
|
|
searchQuery,
|
|
searchResults,
|
|
searchActive,
|
|
searchInProgress,
|
|
searchMoreAvailable,
|
|
conversations,
|
|
conversationInsertAnimations,
|
|
conversationListAnimationMode,
|
|
conversationsLoading,
|
|
hasMoreConversations: hasMore,
|
|
loadingMoreConversations: loadingMore,
|
|
currentConversationId
|
|
} = storeToRefs(conversationStore);
|
|
const { visible: personalPageVisible } = storeToRefs(personalizationStore);
|
|
|
|
const iconStyle = (key: string) => (props.iconStyle ? props.iconStyle(key) : {});
|
|
const displayMode = computed(() => props.displayMode);
|
|
const monitorModeActive = computed(
|
|
() => displayMode.value === 'monitor' || uiChatDisplayMode.value === 'monitor'
|
|
);
|
|
const displayModeDisabled = computed(() => props.displayModeDisabled);
|
|
const openActionMenuId = ref<string | null>(null);
|
|
|
|
const toggleActionMenu = (conversationId: string) => {
|
|
openActionMenuId.value = openActionMenuId.value === conversationId ? null : conversationId;
|
|
};
|
|
|
|
const closeActionMenu = () => {
|
|
openActionMenuId.value = null;
|
|
};
|
|
|
|
onMounted(() => {
|
|
document.addEventListener('click', closeActionMenu);
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
document.removeEventListener('click', closeActionMenu);
|
|
});
|
|
|
|
const displayConversations = computed(() =>
|
|
searchActive.value ? searchResults.value : conversations.value
|
|
);
|
|
const loading = computed(() =>
|
|
searchActive.value ? searchInProgress.value : conversationsLoading.value
|
|
);
|
|
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) =>
|
|
activeConversationIds.value.has(String(conversationId || ''));
|
|
const isConversationCompleted = (conversationId: string) =>
|
|
completedConversationIds.value.has(String(conversationId || ''));
|
|
</script>
|