181 lines
7.4 KiB
Vue
181 lines
7.4 KiB
Vue
<template>
|
|
<!-- 第五批抽离至 ai/MaxRoundsCard.vue(F-260616-03 达 max_iterations 暂停态操作卡,零行为变更)。
|
|
条件:pendingMaxRounds(达 max 事件置)+ 当前视图正在生成(防切走后误显)。
|
|
点继续 → ai_continue_loop(后端续 max_iterations 轮)→ 卡片隐藏(等后端事件)。
|
|
点停止 → ai_stop_loop(后端走 AiCompleted 收尾)→ 卡片隐藏。
|
|
store 单例共享(state/aiApi),pendingMaxRounds 模块级 ref(useAiEvents)直接导入。
|
|
toast 经 emit 转父(保持单一 toast 源)。 -->
|
|
<div v-if="showMaxRoundsCard" ref="maxRoundsRef" class="ai-max-rounds">
|
|
<span class="ai-max-rounds-text">{{ $t('aiChat.maxRoundsReached') }}</span>
|
|
<span class="ai-max-rounds-hint">{{ $t('aiChat.maxRoundsHint') }}</span>
|
|
<div class="ai-max-rounds-actions">
|
|
<button
|
|
class="ai-max-rounds-btn ai-max-rounds-btn--continue"
|
|
:disabled="maxRoundsActing"
|
|
@click="handleContinueLoop"
|
|
>{{ $t('aiChat.continueLoop') }}</button>
|
|
<button
|
|
class="ai-max-rounds-btn ai-max-rounds-btn--stop"
|
|
:disabled="maxRoundsActing"
|
|
@click="handleStopLoop"
|
|
>{{ $t('aiChat.stopLoop') }}</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, watch, nextTick } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { useAiStore } from '../../stores/ai'
|
|
import { getConvState } from '../../composables/ai/useAiEvents'
|
|
import { pendingMaxRounds } from '../../composables/ai/useAiPendingState'
|
|
import { aiApi } from '../../api'
|
|
|
|
const emit = defineEmits<{
|
|
(e: 'toast', payload: { msg: string; type: 'error' | 'warning' | 'info' }): void
|
|
}>()
|
|
|
|
const store = useAiStore()
|
|
const { t } = useI18n()
|
|
|
|
// F-260620 根治(达 max 挂起无声卡死):同 DirAuthDialog,去 streaming 依赖。
|
|
// 达 max(agentic:1186 guard.disarm + AiMaxRoundsReached,generating 保持 true)发生在多轮迭代后,
|
|
// 耗时长,130s 流式看门狗极易在迭代间隙超时清 streaming → 卡片因 streaming=false 不弹 → 用户看不到
|
|
// "继续/停止" → generating 永真卡死。达 max 比 DirAuthAuth 更易触发(必经多轮)。去 streaming,
|
|
// isGenerating(conv) 单条件覆盖挂起态(达 max 时 generatingConvs 含 conv,useAiEvents handleEvent 对非完成/错误事件 add)。
|
|
// 多会话并发 — isGenerating(active) 替代单值比对
|
|
const isViewingGenerating = computed(() =>
|
|
store.isGenerating(store.state.activeConversationId),
|
|
)
|
|
|
|
// L2 状态机(批2 1c):达 max 可靠弹判断改读 conv_state(generating),诊断痛②看门狗误清 streaming
|
|
// 致卡片不弹的根解。conv_state 作新增派生源,旧 isGenerating 双轨过渡兜底——conv_state 未收到
|
|
// 时(CONV_STATE_ENABLED=off 或老后端)回退 isGenerating 判断,不强制全替防回归。
|
|
// 注:达 max 时后端仍 generating(conv_state=generating,审批挂起期亦是 generating),故读
|
|
// conv_state===generating 覆盖挂起态;compressed(压缩派生)也视为活跃可弹(达 max 与压缩互斥,
|
|
// 理论不并存,此处宽放以防边界)。
|
|
const isViewingGeneratingState = computed(() => {
|
|
const cs = getConvState(store.state.activeConversationId)
|
|
if (cs === 'generating' || cs === 'compressed') return true
|
|
if (cs === 'idle' || cs === 'error' || cs === 'stopping') return false
|
|
// conv_state 未追踪过(null)→ 回退旧 isGenerating bool 判断
|
|
return isViewingGenerating.value
|
|
})
|
|
|
|
// pendingMaxRounds(达 max 事件置的挂起 convId)+ 当前视图正在生成(防切走后误显)双重守卫。
|
|
// TD-260621-02 per-conv:pendingMaxRounds 存挂起 convId(string|null),精确比对
|
|
// activeConversationId —— 仅当挂起会话 === 当前展示会话时显操作卡,F-09 并发下不会错显于其他会话。
|
|
// maxRoundsActing 本地 ref 持按钮 loading:点继续/停止后禁双按钮,等后端事件
|
|
// (continueLoop→新一轮 AiAgentRound;stopLoop→AiCompleted)自然清卡片。
|
|
const maxRoundsActing = ref(false)
|
|
const showMaxRoundsCard = computed(() =>
|
|
!!pendingMaxRounds.value
|
|
&& pendingMaxRounds.value === store.state.activeConversationId
|
|
&& isViewingGeneratingState.value,
|
|
)
|
|
|
|
// M8:卡片根 ref。达 max 挂起卡位于输入区上方,多轮迭代后消息流已很长,卡片常被挤出视口 → 用户看不到
|
|
// "继续/停止" → generating 永真卡死。卡片显示时 scrollIntoView 让操作入口立即可见。
|
|
const maxRoundsRef = ref<HTMLElement | null>(null)
|
|
|
|
/** 点继续:调 ai_continue_loop。后端续 max_iterations 轮(iteration 从 0 重计)。
|
|
* 不主动清 pendingMaxRounds——由后端新一轮事件(AiAgentRound)与最终 AiCompleted/AiError
|
|
* 在 useAiEvents 内清。IPC 失败回滚 acting 让用户可重试。 */
|
|
async function handleContinueLoop(): Promise<void> {
|
|
const convId = store.state.activeConversationId
|
|
if (!convId) return
|
|
maxRoundsActing.value = true
|
|
try {
|
|
await aiApi.continueLoop(convId)
|
|
} catch (e) {
|
|
maxRoundsActing.value = false
|
|
const msg = e instanceof Error ? e.message : String(e)
|
|
emit('toast', { msg: t('aiChat.continueLoopFailed', { msg }), type: 'error' })
|
|
}
|
|
}
|
|
|
|
/** 点停止:调 ai_stop_loop。后端复位 generating + emit AiCompleted,useAiEvents 据此
|
|
* 清 pendingMaxRounds。IPC 失败回滚 acting。 */
|
|
async function handleStopLoop(): Promise<void> {
|
|
const convId = store.state.activeConversationId
|
|
if (!convId) return
|
|
maxRoundsActing.value = true
|
|
try {
|
|
await aiApi.stopLoop(convId)
|
|
} catch (e) {
|
|
maxRoundsActing.value = false
|
|
const msg = e instanceof Error ? e.message : String(e)
|
|
emit('toast', { msg: t('aiChat.stopLoopFailed', { msg }), type: 'error' })
|
|
}
|
|
}
|
|
|
|
/** pendingMaxRounds 离开暂停态(后端事件已清 → null)或切到非本会话挂起时复位 acting,允许下次再操作 */
|
|
watch(() => pendingMaxRounds.value, (v) => {
|
|
if (!v) maxRoundsActing.value = false
|
|
})
|
|
|
|
/** M8:卡片显示(从无→有)时 scrollIntoView 让"继续/停止"按钮组立即进入视口。
|
|
* 仅监 showMaxRoundsCard 真值翻转,不监 acting 等内部态避免已可见时反复跳。 */
|
|
watch(showMaxRoundsCard, async (show) => {
|
|
if (!show) return
|
|
await nextTick()
|
|
maxRoundsRef.value?.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.ai-max-rounds {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 3px;
|
|
margin-bottom: 6px;
|
|
padding: 6px 8px;
|
|
background: color-mix(in srgb, var(--df-warning) 10%, transparent);
|
|
border: 0.5px solid color-mix(in srgb, var(--df-warning) 40%, transparent);
|
|
border-radius: var(--df-radius);
|
|
}
|
|
.ai-max-rounds-text {
|
|
font-size: 11.5px;
|
|
font-weight: 600;
|
|
color: var(--df-warning);
|
|
}
|
|
.ai-max-rounds-hint {
|
|
font-size: 10.5px;
|
|
color: var(--df-text-dim);
|
|
opacity: 0.9;
|
|
}
|
|
.ai-max-rounds-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
margin-top: 2px;
|
|
}
|
|
.ai-max-rounds-btn {
|
|
border: none;
|
|
cursor: pointer;
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
padding: 3px 10px;
|
|
border-radius: calc(var(--df-radius) - 2px);
|
|
transition: filter 0.15s var(--df-ease);
|
|
}
|
|
.ai-max-rounds-btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
.ai-max-rounds-btn--continue {
|
|
background: var(--df-warning);
|
|
color: #000;
|
|
}
|
|
.ai-max-rounds-btn--continue:hover:not(:disabled) { filter: brightness(1.1); }
|
|
.ai-max-rounds-btn--stop {
|
|
background: transparent;
|
|
color: var(--df-text-dim);
|
|
border: 0.5px solid var(--df-border);
|
|
}
|
|
.ai-max-rounds-btn--stop:hover:not(:disabled) {
|
|
color: var(--df-text);
|
|
border-color: var(--df-text-dim);
|
|
}
|
|
</style>
|