优化: L2统一状态机完整(ConvState持久化+事件暴露+前端status联合)

1a PerConvState.conv_state持久化(入口/reset/drop写收敛,CONV_STATE_ENABLED门控)
1b AiChatEvent加AiConvStateChanged变体,guard持app_handle在迁移点emit
1c 前端convStates真相源+停止按钮三态(status union)+MaxRoundsCard读conv_state
双轨过渡:enum视图与generating bool并行,off降级可回退
This commit is contained in:
2026-06-22 00:34:43 +08:00
parent d2cada97cd
commit cdb227f999
9 changed files with 264 additions and 13 deletions

View File

@@ -84,8 +84,26 @@
</template>
</div>
</div>
<!-- L2 状态机停止按钮三态(批2 1c):conv_state 派生 generating/stopping/error 三态,
idle( conv_state 未收到回退旧 streaming=false)显发送按钮双轨过渡不强制全替旧 bool -->
<button
v-if="store.state.streaming"
v-if="stopBtnState === 'stopping'"
class="ai-send-btn ai-send-btn--stop ai-send-btn--stopping"
:title="$t('aiChat.stopping')"
disabled
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>
</button>
<button
v-else-if="stopBtnState === 'retry'"
class="ai-send-btn ai-send-btn--stop ai-send-btn--retry"
:title="$t('aiChat.stopFailedRetry')"
@click="store.stopChat"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
</button>
<button
v-else-if="stopBtnState === 'stop'"
class="ai-send-btn ai-send-btn--stop"
:title="$t('aiChat.stopGenerating')"
@click="store.stopChat"
@@ -119,6 +137,7 @@ import { ref, computed, nextTick, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
import { useProjectStore } from '../../stores/project'
import { getConvState } from '../../composables/ai/useAiEvents'
import type { AiMessage, ContentPart, SkillInfo, ProjectRecord, TaskRecord, IdeaRecord, MentionSpan } from '../../api/types'
const props = defineProps<{
@@ -237,6 +256,28 @@ async function onDrop(e: DragEvent): Promise<void> {
/** 发送可用:有文本或图片或已选技能(纯技能调用允许空文本) */
const canSend = computed(() => inputText.value.trim().length > 0 || pendingImages.value.length > 0 || !!pendingSkill.value)
// L2 状态机停止按钮三态(批2 1c):从 conv_state 派生,旧 streaming/generating bool 双轨过渡兜底。
//
// 三态映射(对齐后端 ConvState 语义):
// - 'stopping':ConvState=stopping,停止中 → 显"停止中" disabled(防重复点);
// - 'stop': ConvState=generating(含审批挂起/压缩派生等活跃态)→ 显停止按钮可点;
// - 'retry': ConvState=error,停止失败可重试 → 显重试按钮(再发 stop 信号);
// - 'idle': 空闲(或 conv_state 未收到回退旧 bool=false)→ 显发送按钮。
//
// 兜底:conv_state 未追踪过(getConvState 返回 null,CONV_STATE_ENABLED=off 或老后端)时,
// 回退旧 store.state.streaming 判断(generating→显停止,否则 idle)。不强制全替旧 bool,防回归。
// 注:旧 streaming 是全局单值(非 per-conv),仅当前会话生成时为 true;F-09 多会话并发下 conv_state
// 更精确,但回退路径用 streaming 对单会话场景语义等价。
const stopBtnState = computed<'idle' | 'stop' | 'stopping' | 'retry'>(() => {
const convId = store.state.activeConversationId
const cs = getConvState(convId)
if (cs === 'stopping') return 'stopping'
if (cs === 'error') return 'retry'
if (cs === 'generating' || cs === 'compressed') return 'stop'
// conv_state 未收到(idle 或 null)→ 回退旧 streaming bool
return store.state.streaming ? 'stop' : 'idle'
})
// ── 技能 `/` 联想 ──
const skillOpen = ref(false)
const skillIndex = ref(0)
@@ -912,6 +953,18 @@ defineExpose({
.ai-send-btn--stop:hover {
background: #d63a3f;
}
/* L2 停止中态:disabled 灰显(防重复点),复用 :disabled 通用规则(opacity 0.4) */
.ai-send-btn--stopping {
cursor: not-allowed;
}
/* L2 停止失败可重试态:橙色边框提示(区别于红色停止),hover 加深 */
.ai-send-btn--retry {
background: #e5484d;
color: #fff;
}
.ai-send-btn--retry:hover {
background: #d63a3f;
}
.ai-input-hint {
font-size: 10px;
color: var(--df-text-dim);

View File

@@ -27,7 +27,7 @@
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
import { pendingMaxRounds } from '../../composables/ai/useAiEvents'
import { pendingMaxRounds, getConvState } from '../../composables/ai/useAiEvents'
import { aiApi } from '../../api'
const emit = defineEmits<{
@@ -47,6 +47,20 @@ 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 并发下不会错显于其他会话。
@@ -56,7 +70,7 @@ const maxRoundsActing = ref(false)
const showMaxRoundsCard = computed(() =>
!!pendingMaxRounds.value
&& pendingMaxRounds.value === store.state.activeConversationId
&& isViewingGenerating.value,
&& isViewingGeneratingState.value,
)
/** 点继续:调 ai_continue_loop。后端续 max_iterations 轮(iteration 从 0 重计)。