重构: aichat agent 能力系统化(L1元能力+L2/L3后端+list去重)

L1 agent 元能力层(治痛①死循环零交付):
- env_profile 环境姿势注入 + shell 默认 PowerShell(防引号地狱)
- 断路器:同类工具失败≥3熔断 + guard.reset
- detect_environment 主动探测工具(python/node/shell)
- 求助协议 AiHelpRequired 事件 + 前端求助卡

L2 统一状态机后端(治痛②,前端批2):
- ConvState enum 5态 + 合法转换守卫(conv_state.rs)
- GeneratingGuard 接入视图层(guard.rs)

L3 事件总线后端骨架(治痛③④⑤,接入批2):
- EventBus pub-sub + AiBusEvent 8变体(event_bus.rs)

list 工具调用重复治理第一步:
- build_system_prompt_with_excluded 去重被@实体 + 清单注明语
This commit is contained in:
2026-06-22 00:04:14 +08:00
parent bd6a41fe6e
commit d2cada97cd
16 changed files with 1752 additions and 31 deletions

View File

@@ -326,6 +326,13 @@ export type AiChatEvent = ({
// F-260619-03 Phase B: 路径授权弹窗(LLM 想访问白名单外目录,后端挂起 loop 等用户决定)。
// 前端弹窗三选项:"仅本次"(session)/"未来都允许"(persistent)/"拒绝" → ai_authorize_dir IPC。
type: 'AiDirAuthRequired'; id: string; tool: string; path: string; dir: string
} | {
// L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):
// agent 断路器熔断 / 自省时发,结构化求助(区别于 prompt 教 AI 主动问用户——LLM 不可靠)。
// 字段对齐后端 AiChatEvent::AiHelpRequired:reason(原因摘要)/context(已试情况)/options(建议选项)。
// 前端渲染求助卡显 reason + options 按钮供用户选,与 AiApprovalRequired(工具执行前审批)/
// AiMaxRoundsReached(达 max 被动截断)语义区分——求助=AI 主动「我搞不定」。
type: 'AiHelpRequired'; reason: string; context: string; options: string[]
} | {
// F-260616-07: 流式调用自动重试提示(前端可在错误气泡内显示「重试 n/m」)
type: 'AiStreamRetry'; attempt: number; max_attempts: number

View File

@@ -57,6 +57,10 @@
</div>
<!-- 第五批抽离至 ai/MaxRoundsCard.vue(F-260616-03 达 max_iterations 暂停态操作卡,零行为变更,store 单例 + pendingMaxRounds 模块级 ref 共享,toast 经 emit 转父) -->
<MaxRoundsCard @toast="(p) => showToast(p.msg, p.type)" />
<!-- L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):
断路器熔断 emit AiHelpRequired → pendingHelp(模块级 ref)→ 本卡显 reason + options 按钮供用户选。
机制优先 prompt 说教:代码强制熔断 + 结构化求助卡(非教 AI 主动问用户)。 -->
<HelpRequiredCard @toast="(p) => showToast(p.msg, p.type)" />
<!-- F-260619-03 Phase B: 路径授权弹窗(LLM 访问白名单外目录挂起,三选项 once/always/deny)。
path_auth 审批链阶段3b:统一审批开关 df-ai-unified-approval 开(true,默认)时下线——
path 挂起归一进 ToolCard 内联审批(单真相源:状态层合);关(false,兜底回退)时挂载
@@ -136,6 +140,7 @@ import ChatInput from './ai/ChatInput.vue'
import TopBar from './ai/TopBar.vue'
import MessageList from './ai/MessageList.vue'
import MaxRoundsCard from './ai/MaxRoundsCard.vue'
import HelpRequiredCard from './ai/HelpRequiredCard.vue'
import DirAuthDialog from './ai/DirAuthDialog.vue'
import { useConfirm } from '../composables/useConfirm'
import { useProjectStore } from '../stores/project'

View File

@@ -0,0 +1,105 @@
<template>
<!-- L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):断路器熔断后展示的操作卡
触发:后端断路器连续同类失败达阈值 emit AiHelpRequired( guard.reset,loop 终止)
useAiEvents handleLifecycleEvent pendingHelp(模块级 ref) 本卡渲染
复用 MaxRoundsCard 模式:per-conv 守卫(pendingHelp.conversationId === active)+ 行为已记入本地 ref
reason(原因)+ context(最近错误 sample)+ options 按钮供用户选(换思路/授权路径/人工接管)
option 仅关闭求助卡(后端 loop 已终止,用户重新发消息即"换思路/人工接管"入口,
路径授权走 DirAuthDialog 既有链路)机制优先 prompt 说教:代码强制熔断 + 结构化求助,
"教 AI 失败就问用户"(LLM 不可靠) -->
<div v-if="helpActive" class="ai-help-required">
<span class="ai-help-required-text">{{ helpActive.reason }}</span>
<span class="ai-help-required-hint">{{ helpActive.context }}</span>
<div class="ai-help-required-options">
<button
v-for="opt in helpActive.options"
:key="opt"
class="ai-help-required-btn"
@click="handleSelectOption(opt)"
>{{ opt }}</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
import { pendingHelp } from '../../composables/ai/useAiEvents'
// toast 经 emit 转父(保持单一 toast 源,与 MaxRoundsCard 一致)
const emit = defineEmits<{
(e: 'toast', payload: { msg: string; type: 'error' | 'warning' | 'info' }): void
}>()
const store = useAiStore()
const { t } = useI18n()
// 当前挂起的求助(有值即后端发过 AiHelpRequired)。pendingHelp 由 useAiEvents 翻:
// AiHelpRequired 设值;AiCompleted/AiError 清值(重新发送或错误中断后求助卡消失)。
//
// helpActive 既做守卫(挂起会话===当前展示会话)又做模板取值源:返回 PendingHelp 非空或 null,
// 模板 v-if="helpActive" 取真值后 Vue 类型收窄为非 null,可直接访问 .reason/.context/.options。
// 显示守卫:仅当挂起求助属当前展示会话时显(F-09 多会话并发不串台)。
// 注:不依赖 streaming——求助事件后端已 guard.reset(generating=false),若依赖 isGenerating
// 会导致卡片不显(对齐 MaxRoundsCard F-260620 去 streaming 依赖的根治理)。
const helpActive = computed(() => {
const p = pendingHelp.value
if (p && p.conversationId === store.state.activeConversationId) return p
return null
})
/** 选 option:关求助卡即可。后端 loop 已在 AiHelpRequired 时终止(guard.reset + return),
* 用户选 option 后的"换思路/授权路径/人工接管"由用户重新发消息/操作触发(无独立 IPC)。
* 点"授权路径"提示用户路径授权链路(DirAuthDialog 自动触发,无需此卡代发)。
* 选后清 pendingHelp,卡片隐藏(对齐 MaxRoundsCard 点继续/停止后由事件清的语义,此处无续跑事件故立即清)。 */
function handleSelectOption(opt: string): void {
void opt // option 仅区分按钮文案,后端 loop 已终止无独立 IPC 续跑,选即清卡片
pendingHelp.value = null
emit('toast', { msg: t('aiChat.helpRequiredAck'), type: 'info' })
}
</script>
<style scoped>
/* 复用 MaxRoundsCard 样式基调(warning 色系,求助属"需用户介入"信号,视觉一致) */
.ai-help-required {
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-help-required-text {
font-size: 11.5px;
font-weight: 600;
color: var(--df-warning);
}
.ai-help-required-hint {
font-size: 10.5px;
color: var(--df-text-dim);
opacity: 0.9;
word-break: break-all;
}
.ai-help-required-options {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
margin-top: 2px;
}
.ai-help-required-btn {
border: 0.5px solid color-mix(in srgb, var(--df-warning) 60%, transparent);
background: var(--df-warning);
color: #000;
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-help-required-btn:hover { filter: brightness(1.1); }
</style>

View File

@@ -45,7 +45,8 @@ const appSettings = useAppSettingsStore()
// 模块级 Set 复用,避免 handleEvent 每事件(delta/token 高频)新建数组字面量做 includes。
// F-260616-03: AiMaxRoundsReached 加入——达 max 暂停态等用户决定继续/停止,不计整流超时。
// F-260619-03 Phase B: AiDirAuthRequired 加入——路径授权挂起等用户决定,不计整流超时。
const NO_RESET_WATCHDOG = new Set<AiChatEvent['type']>(['AiApprovalRequired', 'AiCompleted', 'AiError', 'AiMaxRoundsReached', 'AiDirAuthRequired'])
// L1 求助协议(§2.3):AiHelpRequired 加入——断路器熔断已 guard.reset 终止 loop,等用户选 option。
const NO_RESET_WATCHDOG = new Set<AiChatEvent['type']>(['AiApprovalRequired', 'AiCompleted', 'AiError', 'AiMaxRoundsReached', 'AiDirAuthRequired', 'AiHelpRequired'])
// B-260616-12: 工具执行超时提示(纯前端降级,后端无工具级取消 IPC)。
// 每个 running 工具一个独立 setTimeout;到时若仍未收到 Completed/Approval,
@@ -121,6 +122,22 @@ export interface PendingDirAuth {
}
export const pendingDirAuths = ref<PendingDirAuth[]>([])
// L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):
//
// agent 断路器熔断时后端 emit AiHelpRequired(已 guard.reset,generating=false,loop 终止)。
// 前端据此翻 pendingHelp 驱动求助卡(HelpRequiredCard),显 reason + options 按钮供用户选。
// 模块级 ref(不进 store.state,与 pendingMaxRounds/pendingDirAuths 同性质是"待用户操作"信号,
// 独立事件源——求助卡不与达 max 操作卡/审批卡混)。TD-260621-02 per-conv:存挂起 convId,
// 消费方(HelpRequiredCard)守卫按 activeConversationId 精确比对(F-09 多会话并发不串台)。
// 一次只追踪一个挂起求助(后端断路器熔断即 return 终止 loop,用户选 option 前不再发)。
export interface PendingHelp {
reason: string
context: string
options: string[]
conversationId: string | null
}
export const pendingHelp = ref<PendingHelp | null>(null)
/** 通知会话列表发生变化(供 newConversation/deleteConversation/rename/archive 等触发刷新侧栏) */
export function notifyConversationChanged() {
emit('ai-conversation-changed', {})
@@ -468,6 +485,10 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
if (pendingMaxRounds.value && pendingMaxRounds.value === (event.conversation_id || '')) {
pendingMaxRounds.value = null
}
// L1 求助协议(§2.3):求助卡挂起仅清本 conv(新一轮发送时旧求助卡应消失)。
if (pendingHelp.value && pendingHelp.value.conversationId === (event.conversation_id || '')) {
pendingHelp.value = null
}
// TD-260621-03 per-conv:仅清本 conv 的 path_auth 挂起(其他会话的弹窗不连累清空)。
// 批2-A 刚把 pendingMaxRounds per-conv,pendingDirAuths 漏跟;此处补齐——
// F-09 多会话并发下全清会让 B 会话的 DirAuthDialog 凭空消失(用户报"弹窗没了我没操作")。
@@ -533,6 +554,10 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
if (pendingMaxRounds.value && pendingMaxRounds.value === (event.conversation_id || '')) {
pendingMaxRounds.value = null
}
// L1 求助协议(§2.3):求助卡挂起仅清本 conv(错误中断后旧求助卡应消失)。
if (pendingHelp.value && pendingHelp.value.conversationId === (event.conversation_id || '')) {
pendingHelp.value = null
}
// TD-260621-03 per-conv:仅清本 conv 的 path_auth 挂起(异常中断只清本会话弹窗,不连累并发会话)。
const errConv2 = event.conversation_id || ''
pendingDirAuths.value = pendingDirAuths.value.filter(p => !p.conversationId || p.conversationId === errConv2)
@@ -558,6 +583,47 @@ function handleLifecycleEvent(event: AiChatEvent): boolean {
return true
}
case 'AiHelpRequired': {
// L1 求助协议(§2.3,2026-06-21):后端断路器熔断已 guard.reset(generating=false,loop 终止)。
// 前端按终止态收尾(对齐 AiError 分支:清看门狗/流式态/快照/队尾文本 flushCurrentText),
// 但不创建错误气泡(求助非错误,是 AI 主动求助)——改为翻 pendingHelp 驱动求助卡(HelpRequiredCard)
// 显 reason + options 按钮供用户选。
clearStreamWatchdog(event.conversation_id || undefined)
clearAllToolSlowTimers()
flushCurrentText()
setStreaming(false, { convId: event.conversation_id || null, reason: 'AiHelpRequired' })
state.generatingConvs.delete(event.conversation_id || '')
state.currentText = ''
state.agentRound = 0
// 求助即终止 loop,清残留待审批项(对齐 AiError 分支语义,防残留审批卡误导)。
state.pendingApprovals = []
// TD-260621-02 per-conv:仅当 pendingMaxRounds===本 conv 才清 null(求助终止同理清达 max 挂起)。
if (pendingMaxRounds.value && pendingMaxRounds.value === (event.conversation_id || '')) {
pendingMaxRounds.value = null
}
// per-conv 清本 conv path_auth 挂起(求助终止只清本会话弹窗,不连累并发会话)。
const helpConv2 = event.conversation_id || ''
pendingDirAuths.value = pendingDirAuths.value.filter(p => !p.conversationId || p.conversationId === helpConv2)
// F-09: 清理分离窗口生成态快照(per-conv key,清本会话快照;兼容旧单 key)
const helpConv = event.conversation_id || ''
if (helpConv) {
localStorage.removeItem(`df-ai-gen-${helpConv}`)
localStorage.removeItem(`df-ai-text-${helpConv}`)
}
localStorage.removeItem('df-ai-gen')
localStorage.removeItem('df-ai-text')
// 翻 pendingHelp 驱动求助卡:用 convId 兜底(后端必带,无时默认当前活跃会话,优于丢卡片)。
pendingHelp.value = {
reason: event.reason,
context: event.context,
options: event.options,
conversationId: event.conversation_id || state.activeConversationId || null,
}
void loadConversations()
notifyConversationChanged()
return true
}
default:
return false
}
@@ -574,18 +640,19 @@ export function handleEvent(event: AiChatEvent) {
state.activeConversationId = convId
void appSettings.set('df-ai-active-conv', convId)
}
// 事件不属于当前展示对话(生成中切走了)→ 不污染当前视图,仅完成/错误时刷新侧边栏
// 事件不属于当前展示对话(生成中切走了)→ 不污染当前视图,仅完成/错误/求助时刷新侧边栏
// L1 求助协议(§2.3):AiHelpRequired 同 AiError 后端已 guard.reset 终止 loop,需移出生成集。
const isCurrent = !convId || convId === state.activeConversationId
if (!isCurrent) {
if (event.type === 'AiCompleted' || event.type === 'AiError') {
if (event.type === 'AiCompleted' || event.type === 'AiError' || event.type === 'AiHelpRequired') {
// F-09: 多会话并发 — 仅从 Set 移除该 conv(其他会话可能仍在生成),不再置 null 全局清空
state.generatingConvs.delete(convId || '')
void loadConversations()
}
return
}
// 标记正在生成的对话(完成/错误事件除外)
if (convId && event.type !== 'AiCompleted' && event.type !== 'AiError') {
// 标记正在生成的对话(完成/错误/求助事件除外——三者后端均已终止 loop)
if (convId && event.type !== 'AiCompleted' && event.type !== 'AiError' && event.type !== 'AiHelpRequired') {
state.generatingConvs.add(convId)
}
// 流式看门狗:活跃事件(delta/工具/新轮/审批结果)重置;审批等待/完成/错误在 case 内 clear

View File

@@ -145,6 +145,9 @@ export default {
continueLoopFailed: 'Continue failed: {msg}',
stopLoopFailed: 'Stop failed: {msg}',
// ── L1 help protocol (aichat experience & agent capability redesign §2.3, 2026-06-21): circuit-breaker help card ──
helpRequiredAck: 'Choice noted. Please rephrase your request to try another approach / continue',
// ── F-260619-03 Phase B: path authorization dialog (LLM accessing dir outside whitelist) ──
dirAuthRequired: 'Path authorization required',
dirAuthHint: '{tool} wants to access: {path}',

View File

@@ -146,6 +146,9 @@ export default {
continueLoopFailed: '继续失败:{msg}',
stopLoopFailed: '停止失败:{msg}',
// ── L1 求助协议(aichat 体验与 agent 能力系统化重构 §2.3,2026-06-21):断路器熔断求助卡 ──
helpRequiredAck: '已记录选择,请重新描述需求以换思路/继续',
// ── F-260619-03 Phase B: 路径授权弹窗(LLM 访问白名单外目录挂起) ──
dirAuthRequired: '需要路径授权',
dirAuthHint: '{tool} 想访问:{path}',