优化: 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);