新增: 多会话并发前端 UI 阶段3/4(F-09 收尾)

后端 per-conv 并发已就绪(d899c58 + CR-03~07),前端呈现层收尾:

阶段3 多会话生成态:
- stores/ai.ts generatingConvId 单值 → generatingConvs:Set + add/remove/isGenerating helper
- useAiEvents 5 处迁移(Completed/Error/路由标记改 per-conv Set 操作,
  AiError 精确清出错会话非全局清空,修原 = null 误清其他会话)
- ConversationSidebar 多会话并行生成脉冲指示(isGenerating 各会话独立)

阶段4 独立窗口多会话:
- useAiWindow label ai-detached → ai-detached-{convId}(per-conv 独立窗口)
- localStorage 单 key → per-conv key(防多会话串扰,读路径兼容旧 key 回退)

vue-tsc EXIT 0 + generatingConvId 零代码残留(仅注释)
This commit is contained in:
2026-06-19 20:15:37 +08:00
parent 19eb14ffc8
commit 7d5402951b
12 changed files with 181 additions and 53 deletions

View File

@@ -242,7 +242,7 @@ async function confirmClearChat() {
* 新建对话(UX-08 §3.4):生成中点新建 → 弹 ConfirmDialog「当前正在生成,确定中断并新建?」,
* 确认走原软复位流程(B-260615-10 store.newConversation,中断 + 新建);取消不动(保持生成)。
* 非生成中直接走原新建流程(零行为变化)。仅「当前视图正在生成」才弹确认 ——
* 切走后台生成时(streaming && generatingConvId !== activeConversationId)不算中断当前视图。
* 切走后台生成时(streaming && !isGenerating(activeConversationId))不算中断当前视图。
*/
async function confirmNewConversation() {
if (isViewingGenerating.value) {
@@ -277,8 +277,10 @@ const convSidebarRef = ref<InstanceType<typeof ConversationSidebar> | null>(null
// (顶部工具栏 + provider/model 选择逻辑,零行为变更,store 单例共享 modelOverride)。
// 当前视图是否正在生成(切走后台生成时光标不显示)
// F-09: 多会话并发 — 用 isGenerating(activeConversationId) 替代单值比对,
// 当前会话在生成 Set 中即算"视图正在生成"(其他会话后台生成不影响本视图判定)
const isViewingGenerating = computed(() =>
store.state.streaming && store.state.generatingConvId === store.state.activeConversationId
store.state.streaming && store.isGenerating(store.state.activeConversationId)
)
/** UX-09:进入编辑态 — 置 editingMsgId(透传 ChatInput prop)+ 委托 ChatInput.startEditFocus 回填/聚焦。 */
@@ -806,6 +808,22 @@ body.ai-sidebar-resizing * {
font-size: 9px;
color: var(--df-text-dim);
}
/* F-09: 多会话并发生成指示器(会话项标题行内脉冲点,多会话可同时显示) */
.ai-conv-gen-dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--df-primary, #4f8cff);
margin-left: 4px;
vertical-align: middle;
flex-shrink: 0;
animation: ai-conv-gen-pulse 1.4s ease-in-out infinite;
}
@keyframes ai-conv-gen-pulse {
0%, 100% { opacity: 0.35; transform: scale(0.85); }
50% { opacity: 1; transform: scale(1.15); }
}
.ai-conv-item-actions {
display: none;
align-items: center;

View File

@@ -45,7 +45,10 @@
@click="onSelectSearchResult(conv.id)"
>
<div class="ai-conv-item-main">
<span class="ai-conv-item-title" :title="conv.title || ''">{{ conv.title || t('aiChat.newConversation') }}</span>
<span class="ai-conv-item-title" :title="conv.title || ''">
{{ conv.title || t('aiChat.newConversation') }}
<span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span>
</span>
<span class="ai-conv-item-time">{{ formatTime(conv.updated_at) }}</span>
</div>
<div class="ai-conv-item-actions">
@@ -125,6 +128,8 @@
class="ai-conv-item-title-badge"
width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"
><polyline points="20 6 9 17 4 12"/></svg>
<!-- F-09: 多会话并发生成指示器(后台/当前会话生成中均显脉冲点,多会话可同时显示) -->
<span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span>
</span>
<span class="ai-conv-item-time">{{ formatTime(conv.updated_at) }}</span>
</div>
@@ -167,7 +172,10 @@
@click="store.switchConversation(conv.id)"
>
<div class="ai-conv-item-main">
<span class="ai-conv-item-title" :title="conv.title || ''">{{ conv.title || t('aiChat.newConversation') }}</span>
<span class="ai-conv-item-title" :title="conv.title || ''">
{{ conv.title || t('aiChat.newConversation') }}
<span v-if="store.isGenerating(conv.id)" class="ai-conv-gen-dot" :title="t('aiChat.generating')"></span>
</span>
<span class="ai-conv-item-time">{{ formatTime(conv.updated_at) }}</span>
</div>
<div class="ai-conv-item-actions">

View File

@@ -46,8 +46,9 @@ const store = useAiStore()
const { t } = useI18n()
// 当前视图是否正在生成(切走后台生成时光标不显示,与 MaxRoundsCard 一致守卫)
// F-09: 多会话并发 — isGenerating(active) 替代单值比对
const isViewingGenerating = computed(() =>
store.state.streaming && store.state.generatingConvId === store.state.activeConversationId,
store.state.streaming && store.isGenerating(store.state.activeConversationId),
)
// pendingDirAuth(AiDirAuthRequired 事件置)+ 当前视图正在生成(防切走后误显)双重守卫。

View File

@@ -38,8 +38,9 @@ const store = useAiStore()
const { t } = useI18n()
// 当前视图是否正在生成(切走后台生成时光标不显示)
// F-09: 多会话并发 — isGenerating(active) 替代单值比对
const isViewingGenerating = computed(() =>
store.state.streaming && store.state.generatingConvId === store.state.activeConversationId,
store.state.streaming && store.isGenerating(store.state.activeConversationId),
)
// pendingMaxRounds(达 max 事件置)+ 当前视图正在生成(防切走后误显)双重守卫。

View File

@@ -192,8 +192,9 @@ const messagesContainer = ref<HTMLDivElement>()
const toolCardListRef = ref<InstanceType<typeof ToolCardList>>()
// 当前视图是否正在生成(切走后台生成时光标不显示)
// F-09: 多会话并发 — isGenerating(active) 替代单值比对
const isViewingGenerating = computed(() =>
store.state.streaming && store.state.generatingConvId === store.state.activeConversationId
store.state.streaming && store.isGenerating(store.state.activeConversationId)
)
function isLastAi(msg: AiMessage): boolean {
@@ -347,10 +348,10 @@ watch(() => store.state.streaming, (s) => {
if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null }
if (s) {
// UX-260616-03: 新一轮生成开始 → 收起上一轮已完成的旧工具卡,聚焦当前生成内容。
// 仅当前视图正在生成时触发(generatingConvId 对齐 activeConversationId),
// 仅当前视图正在生成时触发(isGenerating(activeConversationId)),
// 切走后台生成/恢复会话(streaming 初值已 true 的 onMounted 路径)不误触。
// buildActiveToolIds/collapseAllToolLists 是 function 声明,提升可用。
if (store.state.generatingConvId === store.state.activeConversationId) {
if (store.isGenerating(store.state.activeConversationId)) {
collapseAllToolLists(buildActiveToolIds(store.state.messages))
}
} else {