新增: 后台任务卡徽章+轮次投影(convRounds), 可恢复分区(parked), reattachToMain i18n 补键(#12)
This commit is contained in:
@@ -781,6 +781,8 @@ export interface AiConversationSummary {
|
|||||||
completion_tokens?: number | null
|
completion_tokens?: number | null
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
|
/** V49 断点续跑:running=进行中 / parked=中断可恢复 / null=无任务(2026-10-06 读侧透出) */
|
||||||
|
loop_state?: 'running' | 'parked' | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** G1 目标钉扎:单条目标记录(含状态追踪) */
|
/** G1 目标钉扎:单条目标记录(含状态追踪) */
|
||||||
|
|||||||
@@ -151,6 +151,30 @@
|
|||||||
<span class="ai-task-progress-fill" :style="{ width: taskProgressPct + '%' }"></span>
|
<span class="ai-task-progress-fill" :style="{ width: taskProgressPct + '%' }"></span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- #14 桌面任务卡:后台在途任务(多会话并发生成中,点击切回) -->
|
||||||
|
<div v-if="runningTasks.length || recoverableTasks.length" class="ai-running-tasks-inline">
|
||||||
|
<button type="button" class="ai-running-tasks-badge ai-tool-badge" :class="{ 'is-active': runningTasksExpanded }" @click="togglePanel('running')" :title="$t('aiChat.runningTasks')">
|
||||||
|
<span class="ai-running-tasks-pulse"></span>
|
||||||
|
{{ runningTasks.length }}
|
||||||
|
</button>
|
||||||
|
<Transition name="ai-pop">
|
||||||
|
<div v-if="runningTasksExpanded" class="ai-running-tasks-list">
|
||||||
|
<button v-for="task in runningTasks" :key="task.id" type="button" class="ai-running-task-item" @click="switchToRunning(task.id)">
|
||||||
|
<span class="ai-running-task-dot"></span>
|
||||||
|
<span class="ai-running-task-title">{{ task.title }}</span>
|
||||||
|
<span class="ai-running-task-round">{{ t('aiChat.runningRounds', { n: task.rounds }) }}</span>
|
||||||
|
</button>
|
||||||
|
<template v-if="recoverableTasks.length">
|
||||||
|
<div class="ai-running-tasks-sep"></div>
|
||||||
|
<button v-for="task in recoverableTasks" :key="task.id" type="button" class="ai-running-task-item" @click="switchToRunning(task.id)">
|
||||||
|
<span class="ai-running-task-dot ai-running-task-dot--parked"></span>
|
||||||
|
<span class="ai-running-task-title">{{ task.title }}</span>
|
||||||
|
<span class="ai-running-task-round ai-running-task-round--parked">{{ t('aiChat.recoverableTask') }}</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
<!-- 目标计数 -->
|
<!-- 目标计数 -->
|
||||||
<div v-if="goals.length" class="ai-goals-inline">
|
<div v-if="goals.length" class="ai-goals-inline">
|
||||||
<button type="button" class="ai-goals-inline-badge ai-tool-badge" :class="{ 'is-active': goalsExpanded }" @click="togglePanel('goals')" :title="$t('aiChat.viewGoals')">
|
<button type="button" class="ai-goals-inline-badge ai-tool-badge" :class="{ 'is-active': goalsExpanded }" @click="togglePanel('goals')" :title="$t('aiChat.viewGoals')">
|
||||||
@@ -258,6 +282,8 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import { useAiStore } from '../../stores/ai'
|
import { useAiStore } from '../../stores/ai'
|
||||||
import { aiApi } from '../../api/ai'
|
import { aiApi } from '../../api/ai'
|
||||||
import { pendingTaskProgress } from '../../composables/ai/useAiPendingState'
|
import { pendingTaskProgress } from '../../composables/ai/useAiPendingState'
|
||||||
|
import { convRounds } from '../../composables/ai/aiShared'
|
||||||
|
import { switchConversation } from '../../composables/ai/useAiConversations'
|
||||||
import type { AiMessage, ModelConfig, GoalEntry } from '../../api/types'
|
import type { AiMessage, ModelConfig, GoalEntry } from '../../api/types'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -414,6 +440,36 @@ const taskProgress = computed(() => {
|
|||||||
const agenticProgressVisible = computed(() =>
|
const agenticProgressVisible = computed(() =>
|
||||||
store.state.streaming && store.state.agentRound > 0,
|
store.state.streaming && store.state.agentRound > 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ── #14 桌面任务卡:后台在途任务列表(多会话并发,切走后可见可切回) ──
|
||||||
|
// 数据源=convRounds(轮次追踪,事件写)× store.isGenerating(状态,同源 convStates),
|
||||||
|
// 排除当前会话(自身进度已有 agentic 实时进度条,不重复)。响应式:convRounds 是
|
||||||
|
// aiShared 模块级 reactive Map,computed 内遍历即建依赖。
|
||||||
|
const runningTasksExpanded = ref(false)
|
||||||
|
const runningTasks = computed(() => {
|
||||||
|
const items: { id: string; title: string; rounds: number }[] = []
|
||||||
|
for (const [id, rounds] of convRounds) {
|
||||||
|
if (id === store.state.activeConversationId) continue
|
||||||
|
if (!store.isGenerating(id)) continue
|
||||||
|
const conv = store.state.conversations.find(c => c.id === id)
|
||||||
|
items.push({ id, title: conv?.title || t('aiChat.untitledConversation'), rounds })
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
// V49 可恢复入口(断点续跑读侧):conversations 中 loop_state='parked' 的会话(重启中断待续),
|
||||||
|
// 独立分区展示在任务面板下方,点击切回(继续与否由用户在会话内决定,不做自动续跑)
|
||||||
|
const recoverableTasks = computed(() => {
|
||||||
|
return store.state.conversations
|
||||||
|
.filter(c => c.loop_state === 'parked' && c.id !== store.state.activeConversationId)
|
||||||
|
.slice(0, 10)
|
||||||
|
.map(c => ({ id: c.id, title: c.title || t('aiChat.untitledConversation') }))
|
||||||
|
})
|
||||||
|
async function switchToRunning(id: string) {
|
||||||
|
runningTasksExpanded.value = false
|
||||||
|
if (id !== store.state.activeConversationId) {
|
||||||
|
await switchConversation(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
const taskProgressText = computed(() => {
|
const taskProgressText = computed(() => {
|
||||||
const p = taskProgress.value
|
const p = taskProgress.value
|
||||||
if (!p) return ''
|
if (!p) return ''
|
||||||
@@ -531,11 +587,11 @@ interface ContextInfo {
|
|||||||
}
|
}
|
||||||
const contextExpanded = ref(false)
|
const contextExpanded = ref(false)
|
||||||
|
|
||||||
/** 弹出面板互斥:goals/history/summary/context 四个 badge 面板,点一个收起其他,再点当前切换。 */
|
/** 弹出面板互斥:goals/history/summary/context/running 五个 badge 面板,点一个收起其他,再点当前切换。 */
|
||||||
function togglePanel(panel: 'goals' | 'history' | 'summary' | 'context') {
|
function togglePanel(panel: 'goals' | 'history' | 'summary' | 'context' | 'running') {
|
||||||
const refs = { goals: goalsExpanded, history: historyExpanded, summary: summaryExpanded, context: contextExpanded }
|
const refs = { goals: goalsExpanded, history: historyExpanded, summary: summaryExpanded, context: contextExpanded, running: runningTasksExpanded }
|
||||||
const willOpen = !refs[panel].value
|
const willOpen = !refs[panel].value
|
||||||
goalsExpanded.value = historyExpanded.value = summaryExpanded.value = contextExpanded.value = false
|
goalsExpanded.value = historyExpanded.value = summaryExpanded.value = contextExpanded.value = runningTasksExpanded.value = false
|
||||||
refs[panel].value = willOpen
|
refs[panel].value = willOpen
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -754,7 +810,7 @@ onBeforeUnmount(() => {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--df-text-dim);
|
color: var(--df-text-dim);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s;
|
transition: background 0.15s, color 0.15s;
|
||||||
}
|
}
|
||||||
.ai-btn-icon:hover {
|
.ai-btn-icon:hover {
|
||||||
background: var(--df-bg-card);
|
background: var(--df-bg-card);
|
||||||
@@ -954,6 +1010,90 @@ onBeforeUnmount(() => {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── #14 桌面任务卡:后台在途任务徽章+面板 ── */
|
||||||
|
.ai-running-tasks-inline {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.ai-running-tasks-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.ai-running-tasks-pulse {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--df-success, #34c77b);
|
||||||
|
animation: ai-running-pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes ai-running-pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.35; }
|
||||||
|
}
|
||||||
|
.ai-running-tasks-list {
|
||||||
|
position: absolute;
|
||||||
|
left: auto; right: 0;
|
||||||
|
top: 100%;
|
||||||
|
z-index: 100;
|
||||||
|
min-width: 260px;
|
||||||
|
max-width: 400px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--df-bg);
|
||||||
|
border: 0.5px solid var(--df-border);
|
||||||
|
border-radius: var(--df-radius-sm);
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
.ai-running-task-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--df-text);
|
||||||
|
}
|
||||||
|
.ai-running-task-item:hover {
|
||||||
|
background: var(--df-hover, rgba(128,128,128,0.08));
|
||||||
|
}
|
||||||
|
.ai-running-task-dot {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--df-success, #34c77b);
|
||||||
|
}
|
||||||
|
.ai-running-task-title {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.ai-running-task-round {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--df-text-secondary, #888);
|
||||||
|
}
|
||||||
|
/* V49 可恢复分区:琥珀点+「可恢复」徽标,与进行中(绿)区分 */
|
||||||
|
.ai-running-tasks-sep {
|
||||||
|
height: 1px;
|
||||||
|
margin: 4px 8px;
|
||||||
|
background: var(--df-border);
|
||||||
|
}
|
||||||
|
.ai-running-task-dot--parked {
|
||||||
|
background: var(--df-warning, #e6a23c);
|
||||||
|
}
|
||||||
|
.ai-running-task-round--parked {
|
||||||
|
color: var(--df-warning, #e6a23c);
|
||||||
|
}
|
||||||
.ai-history-inline-list {
|
.ai-history-inline-list {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: auto; right: 0;
|
left: auto; right: 0;
|
||||||
|
|||||||
@@ -208,6 +208,26 @@ export function clearAllApprovalTimers(): void {
|
|||||||
_approvalTimers.clear()
|
_approvalTimers.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── #14 任务式委托:per-conv 轮次追踪 convRounds(#14 桌面任务卡数据源) ──
|
||||||
|
//
|
||||||
|
// 语义:后台在途会话的 agentic 轮次(AiAgentRound 事件写入口),桌面任务卡显示
|
||||||
|
// 「进行中 N 轮」。模块级 reactive Map(对齐 convStates 模式),conv 终态(AiCompleted/
|
||||||
|
// AiError)时清除——任务卡消费方据「isGenerating && 有轮次记录」渲染在途项。
|
||||||
|
export const convRounds = reactive(new Map<string, number>())
|
||||||
|
|
||||||
|
/** 写入 conv 轮次(AiAgentRound case 唯一写入口,取 max 防事件乱序回退) */
|
||||||
|
export function setConvRound(convId: string | null | undefined, round: number): void {
|
||||||
|
if (!convId) return
|
||||||
|
const cur = convRounds.get(convId) ?? 0
|
||||||
|
if (round > cur) convRounds.set(convId, round)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清除 conv 轮次(终态收敛:与 setConvState idle 删除同语义) */
|
||||||
|
export function clearConvRound(convId: string | null | undefined): void {
|
||||||
|
if (!convId) return
|
||||||
|
convRounds.delete(convId)
|
||||||
|
}
|
||||||
|
|
||||||
// ── L2 统一状态机 convStates(下沉自 useAiEvents,批4 双轨收口) ──
|
// ── L2 统一状态机 convStates(下沉自 useAiEvents,批4 双轨收口) ──
|
||||||
//
|
//
|
||||||
// 下沉原因:批4 退役 generatingConvs(bool 轨)后,stores/ai.ts(isGenerating)/
|
// 下沉原因:批4 退役 generatingConvs(bool 轨)后,stores/ai.ts(isGenerating)/
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
//! AI 事件监听与分发(分派器)— startListener / stopListener / handleEvent
|
//! AI 事件监听与分发(分派器)— startListener(租约)/ ensureListenerStarted(幂等确保)/ handleEvent
|
||||||
//!
|
//!
|
||||||
//! 拆分(批次 A-B1):14 个事件 case 按域拆到 3 个叶子 handler:
|
//! 拆分(批次 A-B1):14 个事件 case 按域拆到 3 个叶子 handler:
|
||||||
//! - useAiStreamingEvents.handleStreamingEvent(流式)
|
//! - useAiStreamingEvents.handleStreamingEvent(流式)
|
||||||
@@ -16,7 +16,7 @@ import { t } from '@/i18n/i18n-helpers'
|
|||||||
import { aiApi } from '@/api'
|
import { aiApi } from '@/api'
|
||||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||||
import { state } from '@/stores/ai'
|
import { state } from '@/stores/ai'
|
||||||
import { nextMsgId, setConvState, convStates, switchingConvs, getConvStreamState, setConvCurrentText, setConvStreaming, clearConvStreamState, clearAllApprovalTimers, clearAllToolSlowTimers, flushCurrentText, friendlyError, notifyConversationChanged, clearLastDelta, discardConvPendingApprovals, addConvAgentFinished, clearConvAgentFinished, type AgentFinishedStatus } from './aiShared'
|
import { nextMsgId, setConvState, convStates, switchingConvs, getConvStreamState, setConvCurrentText, setConvStreaming, clearConvStreamState, clearAllApprovalTimers, clearAllToolSlowTimers, flushCurrentText, friendlyError, notifyConversationChanged, clearLastDelta, discardConvPendingApprovals, addConvAgentFinished, clearConvAgentFinished, setConvRound, clearConvRound, type AgentFinishedStatus } from './aiShared'
|
||||||
import { resetStreamWatchdog, clearAllStreamWatchdogs, clearResumeAttempts } from './useAiStream'
|
import { resetStreamWatchdog, clearAllStreamWatchdogs, clearResumeAttempts } from './useAiStream'
|
||||||
import { setStreaming } from './streamingGuard'
|
import { setStreaming } from './streamingGuard'
|
||||||
import { scheduleConversationsRefresh, activeConvKvKey } from './useAiConversations'
|
import { scheduleConversationsRefresh, activeConvKvKey } from './useAiConversations'
|
||||||
@@ -40,6 +40,9 @@ let _unlistenApprovalClear: (() => void) | null = null
|
|||||||
// startListener 并发去重:防 onMounted 与 sendMessage 首发竞态下重复注册 listener
|
// startListener 并发去重:防 onMounted 与 sendMessage 首发竞态下重复注册 listener
|
||||||
// (两回调写同一 state.currentText → 流式文字双倍)
|
// (两回调写同一 state.currentText → 流式文字双倍)
|
||||||
let _startPromise: Promise<void> | null = null
|
let _startPromise: Promise<void> | null = null
|
||||||
|
// 租约计数(所有权模型,2026-10-05 根本修):仅组件挂载级 startListener 借/还,
|
||||||
|
// send 等运行时路径走 ensureListenerStarted 不计数——计数只由对称生命周期驱动,不漂移。
|
||||||
|
let _leaseCount = 0
|
||||||
|
|
||||||
const appSettings = useAppSettingsStore()
|
const appSettings = useAppSettingsStore()
|
||||||
|
|
||||||
@@ -219,6 +222,8 @@ export function handleEvent(event: AiChatEvent) {
|
|||||||
clearResumeAttempts(convId || undefined)
|
clearResumeAttempts(convId || undefined)
|
||||||
// 终态即清进度快照(事件即逝态,会话已终止不留陈旧「进行中」)
|
// 终态即清进度快照(事件即逝态,会话已终止不留陈旧「进行中」)
|
||||||
pendingTaskProgress.delete(convId || '')
|
pendingTaskProgress.delete(convId || '')
|
||||||
|
// #14 桌面任务卡:终态即清轮次(任务卡据 convRounds 渲染在途项,终止不留陈旧项)
|
||||||
|
clearConvRound(convId || '')
|
||||||
// FE1:后台会话终止只刷新列表(防抖调度,避免与 AiCompleted 收尾的 notify 双拉)。
|
// FE1:后台会话终止只刷新列表(防抖调度,避免与 AiCompleted 收尾的 notify 双拉)。
|
||||||
scheduleConversationsRefresh()
|
scheduleConversationsRefresh()
|
||||||
// 后台会话终止也触发队列续发/清队(完成续发;错误清该会话队列)
|
// 后台会话终止也触发队列续发/清队(完成续发;错误清该会话队列)
|
||||||
@@ -238,6 +243,10 @@ export function handleEvent(event: AiChatEvent) {
|
|||||||
// 切换完成后由渲染层续显,避免切换窗口丢可见内容
|
// 切换完成后由渲染层续显,避免切换窗口丢可见内容
|
||||||
setConvStreaming(convId, true)
|
setConvStreaming(convId, true)
|
||||||
setConvCurrentText(convId, (getConvStreamState(convId)?.currentText ?? '') + (event.delta ?? ''))
|
setConvCurrentText(convId, (getConvStreamState(convId)?.currentText ?? '') + (event.delta ?? ''))
|
||||||
|
} else if (event.type === 'AiAgentRound' && convId && event.round > 0) {
|
||||||
|
// #14 桌面任务卡:后台会话轮次追踪(isCurrent=false 原本直接丢弃本事件,
|
||||||
|
// 任务卡需要后台轮次显示「进行中 N 轮」;取 max 防事件乱序回退)
|
||||||
|
setConvRound(convId, event.round)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -260,8 +269,30 @@ export function handleEvent(event: AiChatEvent) {
|
|||||||
void handleLifecycleEvent(event)
|
void handleLifecycleEvent(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 启动事件监听(幂等 + 并发去重,onMounted 与 sendMessage 首发竞态不会重复注册) */
|
/** 启动事件监听(幂等确保注册,无所有权——send 等运行时路径调用,不计入租约)。
|
||||||
export async function startListener() {
|
* 所有权模型(2026-10-05 根本修):
|
||||||
|
* - ensureListenerStarted:仅保证 listener 存在,谁都可调、任意次,不持租约;
|
||||||
|
* - startListener:组件挂载用,返回 release 租约句柄,unmount 时调用归还;
|
||||||
|
* 最后一份租约归还才真拆 listener + 清全局计时器。
|
||||||
|
* 两者分离根治「send 裸调 start 无配对 stop → 裸计数只涨不跌 → 永不真拆」的结构性漂移:
|
||||||
|
* 计数只由组件挂载/卸载这一对对称生命周期驱动,不变量可静态审计。 */
|
||||||
|
export async function startListener(): Promise<() => void> {
|
||||||
|
await ensureListenerStarted()
|
||||||
|
_leaseCount++
|
||||||
|
let released = false
|
||||||
|
return () => {
|
||||||
|
if (released) {
|
||||||
|
// 双重归还是调用方 bug(组件卸载钩子重复执行等),dev 下即刻暴露防静默漂移
|
||||||
|
if (import.meta.env.DEV) console.error('[useAiEvents] listener 租约重复归还(调用方 bug)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
released = true
|
||||||
|
releaseLease()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 幂等确保 listener 已注册(并发去重;不持租约,不触发拆卸)。 */
|
||||||
|
export async function ensureListenerStarted(): Promise<void> {
|
||||||
if (_unlistenAiEvent && _unlistenConvChanged && _unlistenApprovalClear) {
|
if (_unlistenAiEvent && _unlistenConvChanged && _unlistenApprovalClear) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -304,8 +335,14 @@ export async function startListener() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 停止事件监听(卸载时调用,释放后端 listener + 清看门狗/计时器,防回调改已卸载组件 state) */
|
/** 归还一份租约:计数归零才真拆(多实例挂载时先退出的实例不误拆他人在用的 listener)。 */
|
||||||
export function stopListener() {
|
function releaseLease() {
|
||||||
|
if (_leaseCount === 0) {
|
||||||
|
if (import.meta.env.DEV) console.error('[useAiEvents] 租约下溢(归还多于借出,调用方 bug)')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_leaseCount--
|
||||||
|
if (_leaseCount > 0) return
|
||||||
_unlistenAiEvent?.()
|
_unlistenAiEvent?.()
|
||||||
_unlistenConvChanged?.()
|
_unlistenConvChanged?.()
|
||||||
_unlistenApprovalClear?.()
|
_unlistenApprovalClear?.()
|
||||||
@@ -323,7 +360,7 @@ export function stopListener() {
|
|||||||
export function useAiEvents() {
|
export function useAiEvents() {
|
||||||
return {
|
return {
|
||||||
startListener,
|
startListener,
|
||||||
stopListener,
|
ensureListenerStarted,
|
||||||
handleEvent,
|
handleEvent,
|
||||||
flushCurrentText,
|
flushCurrentText,
|
||||||
friendlyError,
|
friendlyError,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { emit } from '@tauri-apps/api/event'
|
|||||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||||
import { state } from '@/stores/ai'
|
import { state } from '@/stores/ai'
|
||||||
import { t } from '@/i18n/i18n-helpers'
|
import { t } from '@/i18n/i18n-helpers'
|
||||||
import { nextMsgId, convStates, clearApprovalTimer, flushCurrentText, clearAllToolSlowTimers, friendlyError, notifyConversationChanged, clearLastDelta, discardConvPendingApprovals, notifyQueueCleared } from './aiShared'
|
import { nextMsgId, convStates, clearApprovalTimer, flushCurrentText, clearAllToolSlowTimers, friendlyError, notifyConversationChanged, clearLastDelta, discardConvPendingApprovals, notifyQueueCleared, clearConvRound } from './aiShared'
|
||||||
import { clearTextIdleTimer, pendingMaxRounds, pendingHelp, pendingDirAuths, pendingTaskParked, pendingTaskProgress } from './useAiPendingState'
|
import { clearTextIdleTimer, pendingMaxRounds, pendingHelp, pendingDirAuths, pendingTaskParked, pendingTaskProgress } from './useAiPendingState'
|
||||||
import { clearStreamWatchdog, clearResumeAttempts } from './useAiStream'
|
import { clearStreamWatchdog, clearResumeAttempts } from './useAiStream'
|
||||||
import { setStreaming } from './streamingGuard'
|
import { setStreaming } from './streamingGuard'
|
||||||
@@ -74,6 +74,8 @@ function cleanupTerminatedConversation(convId: string, reason: 'AiCompleted' | '
|
|||||||
}
|
}
|
||||||
setStreaming(false, { convId: convId || null, reason })
|
setStreaming(false, { convId: convId || null, reason })
|
||||||
state.agentRound = 0 // 结束/中断/求助,复位轮次(隐藏进度条)
|
state.agentRound = 0 // 结束/中断/求助,复位轮次(隐藏进度条)
|
||||||
|
// #14 桌面任务卡:终态即清 per-conv 轮次(前台会话终态收敛,与后台分支 clearConvRound 对齐)
|
||||||
|
clearConvRound(convId)
|
||||||
// convStates 收敛:AiError 保留 error 项(停止按钮显「重试」态),其余删项回 idle
|
// convStates 收敛:AiError 保留 error 项(停止按钮显「重试」态),其余删项回 idle
|
||||||
if (reason === 'AiError') {
|
if (reason === 'AiError') {
|
||||||
if (convId) convStates.set(convId, 'error')
|
if (convId) convStates.set(convId, 'error')
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import type { ContentPart, AiMessage, MentionSpan, MessageId } from '@/api/types
|
|||||||
import { t } from '@/i18n/i18n-helpers'
|
import { t } from '@/i18n/i18n-helpers'
|
||||||
import { resetStreamWatchdog, clearStreamWatchdog, clearResumeAttempts } from './useAiStream'
|
import { resetStreamWatchdog, clearStreamWatchdog, clearResumeAttempts } from './useAiStream'
|
||||||
import { setStreaming } from './streamingGuard'
|
import { setStreaming } from './streamingGuard'
|
||||||
import { startListener } from './useAiEvents'
|
import { ensureListenerStarted } from './useAiEvents'
|
||||||
import { nextMsgId, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers, resolveAiLang, convStates, setConvCurrentText, setConvStreaming, flushCurrentText, notifyQueueCleared, classifySendError, type SendErrorKind } from './aiShared'
|
import { nextMsgId, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers, resolveAiLang, convStates, setConvCurrentText, setConvStreaming, flushCurrentText, notifyQueueCleared, classifySendError, type SendErrorKind } from './aiShared'
|
||||||
|
|
||||||
/// UX-P2-A5:drainQueue 失败气泡的错误分类文案映射(分类逻辑 aiShared.classifySendError,
|
/// UX-P2-A5:drainQueue 失败气泡的错误分类文案映射(分类逻辑 aiShared.classifySendError,
|
||||||
@@ -117,7 +117,7 @@ async function doSend(text: string, skill?: string, force = false, parts?: Conte
|
|||||||
setConvCurrentText(targetConvId, '')
|
setConvCurrentText(targetConvId, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
await startListener()
|
await ensureListenerStarted()
|
||||||
const lang = resolveAiLang()
|
const lang = resolveAiLang()
|
||||||
try {
|
try {
|
||||||
// force=true 走 force_send IPC(复位残留 generating),否则走正常 send
|
// force=true 走 force_send IPC(复位残留 generating),否则走正常 send
|
||||||
@@ -183,7 +183,7 @@ async function regenerate() {
|
|||||||
// 会话态走 per-conv 键(与 doSend 一致),不再落 legacy 单 timer fallback
|
// 会话态走 per-conv 键(与 doSend 一致),不再落 legacy 单 timer fallback
|
||||||
resetStreamWatchdog(regenConvId || undefined)
|
resetStreamWatchdog(regenConvId || undefined)
|
||||||
|
|
||||||
await startListener()
|
await ensureListenerStarted()
|
||||||
const lang = resolveAiLang()
|
const lang = resolveAiLang()
|
||||||
const convId = state.activeConversationId
|
const convId = state.activeConversationId
|
||||||
if (!convId) {
|
if (!convId) {
|
||||||
@@ -259,7 +259,7 @@ async function editMessage(newMessage: string) {
|
|||||||
// 会话态走 per-conv 键(与 doSend 一致),不再落 legacy 单 timer fallback
|
// 会话态走 per-conv 键(与 doSend 一致),不再落 legacy 单 timer fallback
|
||||||
resetStreamWatchdog(editConvId || undefined)
|
resetStreamWatchdog(editConvId || undefined)
|
||||||
|
|
||||||
await startListener()
|
await ensureListenerStarted()
|
||||||
const lang = resolveAiLang()
|
const lang = resolveAiLang()
|
||||||
const convId = state.activeConversationId
|
const convId = state.activeConversationId
|
||||||
if (!convId) {
|
if (!convId) {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { emit } from '@tauri-apps/api/event'
|
|||||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||||
import { state } from '@/stores/ai'
|
import { state } from '@/stores/ai'
|
||||||
import { t } from '@/i18n/i18n-helpers'
|
import { t } from '@/i18n/i18n-helpers'
|
||||||
import { nextMsgId, findToolCall, startApprovalTimer, clearToolSlowTimer, getLastDelta, setLastDelta, flushCurrentText, addPendingApproval } from './aiShared'
|
import { nextMsgId, findToolCall, startApprovalTimer, clearToolSlowTimer, getLastDelta, setLastDelta, flushCurrentText, addPendingApproval, setConvRound } from './aiShared'
|
||||||
import { resetTextIdleTimer, clearTextIdleTimer, pendingDirAuths, pushPendingDirAuth } from './useAiPendingState'
|
import { resetTextIdleTimer, clearTextIdleTimer, pendingDirAuths, pushPendingDirAuth } from './useAiPendingState'
|
||||||
import { clearStreamWatchdog } from './useAiStream'
|
import { clearStreamWatchdog } from './useAiStream'
|
||||||
import type { AiChatEvent, AiMessage, MessageId } from '@/api/types'
|
import type { AiChatEvent, AiMessage, MessageId } from '@/api/types'
|
||||||
@@ -57,6 +57,10 @@ export function handleStreamingEvent(event: AiChatEvent): boolean {
|
|||||||
})
|
})
|
||||||
// round>0 才更新进度条(round=0 是审批通过后「隔开新一轮」的占位,实际轮次未推进,防闪烁)
|
// round>0 才更新进度条(round=0 是审批通过后「隔开新一轮」的占位,实际轮次未推进,防闪烁)
|
||||||
if (event.round > 0) state.agentRound = event.round
|
if (event.round > 0) state.agentRound = event.round
|
||||||
|
// #14 桌面任务卡:per-conv 轮次追踪,前台会话在此写(取 max 防事件乱序回退);
|
||||||
|
// 后台会话在 useAiEvents 非 current 分支的 return 前补写(彼处 AiAgentRound
|
||||||
|
// 原本直接穿透丢弃,任务卡需要后台轮次才能显示「进行中 N 轮」)
|
||||||
|
if (event.round > 0) setConvRound(event.conversation_id || state.activeConversationId, event.round)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { nextMsgId, getConvState, convStates } from './aiShared'
|
|||||||
import type { MessageId } from '@/api/types'
|
import type { MessageId } from '@/api/types'
|
||||||
import { setStreaming } from './streamingGuard'
|
import { setStreaming } from './streamingGuard'
|
||||||
import { persistUiState } from './useAiPanel'
|
import { persistUiState } from './useAiPanel'
|
||||||
import { stopListener } from './useAiEvents'
|
|
||||||
|
|
||||||
// ── 主窗口事件跟随 unlistener ──
|
// ── 主窗口事件跟随 unlistener ──
|
||||||
let _unlistenMove: (() => void) | null = null
|
let _unlistenMove: (() => void) | null = null
|
||||||
@@ -200,19 +200,19 @@ async function detachPanel(convId?: string) {
|
|||||||
stopFollowMain()
|
stopFollowMain()
|
||||||
})
|
})
|
||||||
state.detached = true
|
state.detached = true
|
||||||
// 时序加固:detached 置 true 后立即停主窗口 listener,不依赖 v-if → AiChat onBeforeUnmount 的
|
// 时序注(2026-10-05 租约模型):原此处同步调 stopListener() 提前拆主窗 listener,消除
|
||||||
// 异步传播。消除 detached=true 到 AiChat 实际卸载的窗口期(reattach 重叠期/HMR/极速 mount-unmount
|
// detached=true 到 AiChat 实际卸载的窗口期竞态——但同步拆全局单例正是双实例互踩根源,
|
||||||
// 竞态下,onBeforeUnmount 可能延迟,旧 listener 残留会与分离窗口新 listener 并存竞态)。
|
// 租约模型下删除:主窗 v-if 立即失效 → onBeforeUnmount 归还租约(最后一份才真拆)。
|
||||||
// stopListener 同步释放后端 listener + 清看门狗,分离窗口 resumeInDetached 起自己的 startListener。
|
// 竞态窗口期(毫秒级)旧 listener 多活无害:事件按 conversation_id 路由,分离窗口独立注册
|
||||||
|
// (BUG-260624-02 已实证审批事件无缺口),主窗残听仅多收一次同事件,无串台。
|
||||||
//
|
//
|
||||||
// BUG-260624-02 核验结论(2026-08-15):分离窗口事件监听独立存在,审批事件无缺口。
|
// BUG-260624-02 核验结论(2026-08-15):分离窗口事件监听独立存在,审批事件无缺口。
|
||||||
// 链路:本窗口 url '#/ai-detached' → AiDetached.vue 挂 AiChat(detached) → AiChat onMounted
|
// 链路:本窗口 url '#/ai-detached' → AiDetached.vue 挂 AiChat(detached) → AiChat onMounted
|
||||||
// 调 store.startListener()(独立 JS context 各自注册);后端 ai-chat-event 为 app 级广播
|
// 调 store.startListener()(独立 JS context 各自注册);后端 ai-chat-event 为 app 级广播
|
||||||
// (emit 到全部 webview),故审批类事件(工具 pending/审批结果)分离窗口自身可收。
|
// (emit 到全部 webview),故审批类事件(工具 pending/审批结果)分离窗口自身可收。
|
||||||
// 前端派生的 'ai-pending-arrived'(useAiStreamingEvents/useAiToolEvents emit)亦为全局广播,
|
// 前端派生的 'ai-pending-arrived'(useAiStreamingEvents/useAiToolEvents emit)亦为全局广播,
|
||||||
// 分离窗口 ToolCardList 的 listener 同样收到。主窗口 listener 在此 stopListener 释清,
|
// 分离窗口 ToolCardList 的 listener 同样收到。主窗 listener 由租约归还(见上方时序注)释清,
|
||||||
// 不与分离窗口并存竞态。审批超时计时器(aiShared)同样随 listener 在接管窗口内重建。
|
// 不与分离窗口并存竞态。审批超时计时器(aiShared)同样随 listener 在接管窗口内重建。
|
||||||
stopListener()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 关闭分离窗口并回到内嵌面板(主窗口侧调用)。
|
/** 关闭分离窗口并回到内嵌面板(主窗口侧调用)。
|
||||||
@@ -234,9 +234,9 @@ async function reattachPanel() {
|
|||||||
persistUiState()
|
persistUiState()
|
||||||
// V11c: 清分离窗活跃会话 KV(分离窗已全关,残留无意义;下次 detachPanel 会重新种子)
|
// V11c: 清分离窗活跃会话 KV(分离窗已全关,残留无意义;下次 detachPanel 会重新种子)
|
||||||
void useAppSettingsStore().remove('df-ai-active-conv-detached')
|
void useAppSettingsStore().remove('df-ai-active-conv-detached')
|
||||||
// 时序加固:reattach 关闭分离窗口后,显式停分离窗口残留 listener(分离窗口 close 异步,
|
// 时序注(租约模型):原此处显式 stopListener 防「分离窗旧 listener 与主面板重挂载重叠」——
|
||||||
// onBeforeUnmount stopListener 可能延迟),防主面板重新挂载 startListener 时与旧 listener 重叠竞态。
|
// 但分离窗 listener 活在分离窗自己的 JS context,主窗此处拆的只是本 context 已归还空的
|
||||||
stopListener()
|
// listener(跨 webview 隔离,原调用实为 no-op 式心理安慰)。租约归还链已完整覆盖,删除。
|
||||||
// 清所有 per-conv 快照(分离窗口已全关,快照无消费方)
|
// 清所有 per-conv 快照(分离窗口已全关,快照无消费方)
|
||||||
cleanupAllDetachedSnapshots()
|
cleanupAllDetachedSnapshots()
|
||||||
}
|
}
|
||||||
@@ -348,9 +348,8 @@ async function resumeInDetached(convId: string | null) {
|
|||||||
async function closeDetachedWindow() {
|
async function closeDetachedWindow() {
|
||||||
state.docked = false
|
state.docked = false
|
||||||
stopFollowMain()
|
stopFollowMain()
|
||||||
// 时序加固:关闭分离窗口前先停 listener,防 close 异步传播期间 onBeforeUnmount 延迟、
|
// 时序注(租约模型):原此处关闭前先 stopListener 防竞态——同上,租约归还链
|
||||||
// 主面板重挂载 startListener 与旧分离窗口 listener 短暂并存(极速 mount-unmount/HMR 竞态)。
|
// (本窗口 AiChat onBeforeUnmount)已完整覆盖,强拆会使租约计数与实际状态失配,删除。
|
||||||
stopListener()
|
|
||||||
const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||||
const currentWin = getCurrentWebviewWindow()
|
const currentWin = getCurrentWebviewWindow()
|
||||||
const lbl = currentWin.label
|
const lbl = currentWin.label
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export default {
|
|||||||
restoreSidebar: 'Restore sidebar',
|
restoreSidebar: 'Restore sidebar',
|
||||||
sidebarResizeHint: 'Drag to resize sidebar',
|
sidebarResizeHint: 'Drag to resize sidebar',
|
||||||
detachWindow: 'Detach window',
|
detachWindow: 'Detach window',
|
||||||
|
reattachToMain: 'Reattach to main panel',
|
||||||
closePanel: 'Close panel',
|
closePanel: 'Close panel',
|
||||||
dockRight: 'Dock to main window right',
|
dockRight: 'Dock to main window right',
|
||||||
undock: 'Undock',
|
undock: 'Undock',
|
||||||
@@ -255,6 +256,8 @@ export default {
|
|||||||
autoCompressed: 'Context auto-compressed (earlier messages folded into summary)',
|
autoCompressed: 'Context auto-compressed (earlier messages folded into summary)',
|
||||||
compressError: 'Compression failed: {msg}',
|
compressError: 'Compression failed: {msg}',
|
||||||
clearError: 'Clear failed: {msg}',
|
clearError: 'Clear failed: {msg}',
|
||||||
|
initError: 'AI panel init failed: {msg} (reopen panel to retry)',
|
||||||
|
initDegraded: 'List load failed, continuing in degraded mode: {msg}',
|
||||||
// Separator expand / collapse
|
// Separator expand / collapse
|
||||||
expand: 'Expand',
|
expand: 'Expand',
|
||||||
collapse: 'Collapse',
|
collapse: 'Collapse',
|
||||||
@@ -263,6 +266,10 @@ export default {
|
|||||||
|
|
||||||
// ── TopBar bottom context badges (tooltip + L3 full context popover labels) ──
|
// ── TopBar bottom context badges (tooltip + L3 full context popover labels) ──
|
||||||
viewGoals: 'View goals',
|
viewGoals: 'View goals',
|
||||||
|
runningTasks: 'Running background tasks',
|
||||||
|
runningRounds: '{n} rounds running',
|
||||||
|
untitledConversation: 'Untitled conversation',
|
||||||
|
recoverableTask: 'Interrupted · recoverable',
|
||||||
viewHistory: 'View history messages',
|
viewHistory: 'View history messages',
|
||||||
viewSummary: 'View context summary',
|
viewSummary: 'View context summary',
|
||||||
viewContext: 'View AI context',
|
viewContext: 'View AI context',
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export default {
|
|||||||
restoreSidebar: '还原侧栏',
|
restoreSidebar: '还原侧栏',
|
||||||
sidebarResizeHint: '拖拽调整侧栏宽度',
|
sidebarResizeHint: '拖拽调整侧栏宽度',
|
||||||
detachWindow: '分离窗口',
|
detachWindow: '分离窗口',
|
||||||
|
reattachToMain: '收回主面板',
|
||||||
closePanel: '关闭面板',
|
closePanel: '关闭面板',
|
||||||
dockRight: '吸附到主窗口右侧',
|
dockRight: '吸附到主窗口右侧',
|
||||||
undock: '取消吸附',
|
undock: '取消吸附',
|
||||||
@@ -256,6 +257,8 @@ export default {
|
|||||||
autoCompressed: '上下文已自动压缩(较早消息已折叠为摘要)',
|
autoCompressed: '上下文已自动压缩(较早消息已折叠为摘要)',
|
||||||
compressError: '压缩失败:{msg}',
|
compressError: '压缩失败:{msg}',
|
||||||
clearError: '清空失败:{msg}',
|
clearError: '清空失败:{msg}',
|
||||||
|
initError: 'AI 面板初始化失败:{msg}(重开面板可重试)',
|
||||||
|
initDegraded: '列表加载失败,面板降级继续:{msg}',
|
||||||
// 折叠条展开 / 收起
|
// 折叠条展开 / 收起
|
||||||
expand: '展开',
|
expand: '展开',
|
||||||
collapse: '收起',
|
collapse: '收起',
|
||||||
@@ -264,6 +267,10 @@ export default {
|
|||||||
|
|
||||||
// ── TopBar 底部上下文徽章(tooltip + L3 完整上下文 popover 标签) ──
|
// ── TopBar 底部上下文徽章(tooltip + L3 完整上下文 popover 标签) ──
|
||||||
viewGoals: '查看目标',
|
viewGoals: '查看目标',
|
||||||
|
runningTasks: '后台进行中任务',
|
||||||
|
runningRounds: '进行中 {n} 轮',
|
||||||
|
untitledConversation: '未命名会话',
|
||||||
|
recoverableTask: '中断·可恢复',
|
||||||
viewHistory: '查看历史消息',
|
viewHistory: '查看历史消息',
|
||||||
viewSummary: '查看上下文摘要',
|
viewSummary: '查看上下文摘要',
|
||||||
viewContext: '查看 AI 上下文',
|
viewContext: '查看 AI 上下文',
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
//! - composables 之间通过模块级 import 直接互调(events↔send↔stream 耦合)
|
//! - composables 之间通过模块级 import 直接互调(events↔send↔stream 耦合)
|
||||||
//!
|
//!
|
||||||
//! composables 职责:
|
//! composables 职责:
|
||||||
//! - useAiEvents startListener/stopListener/handleEvent/flushCurrentText/findToolCall/friendlyError/notifyConversationChanged
|
//! - useAiEvents startListener/ensureListenerStarted/handleEvent/flushCurrentText/findToolCall/friendlyError/notifyConversationChanged
|
||||||
//! - useAiStream onStreamTimeout/resetStreamWatchdog/clearStreamWatchdog + STREAM_TIMEOUT_MS
|
//! - useAiStream onStreamTimeout/resetStreamWatchdog/clearStreamWatchdog + STREAM_TIMEOUT_MS
|
||||||
//! - useAiSend sendMessage/approveToolCall/drainQueue/cancelQueued/clearQueue/stopChat
|
//! - useAiSend sendMessage/approveToolCall/drainQueue/cancelQueued/clearQueue/stopChat
|
||||||
//! - useAiConversations loadConversations/newConversation/switchConversation/deleteConversation/renameConversation/archiveConversation/toggleArchivedFold/toggleSidebar
|
//! - useAiConversations loadConversations/newConversation/switchConversation/deleteConversation/renameConversation/archiveConversation/toggleArchivedFold/toggleSidebar
|
||||||
|
|||||||
Reference in New Issue
Block a user