修复: 飞书走查六项 + 停靠状态写侧根因解 + agentic/audit 加固
飞书集成走查六项(feishu_channel.rs +420):
1. 审批卡按钮化: on_card_action + handle_card_action(解析 {approval_id,approved}
→ ai_approve) + send_approval_button_card(Card JSON 1.0 双按钮, 失败降级文本卡)
2. 首帧流式提速: AiTextDelta 首个 delta 立即刷占位卡, 不等 3s ticker
3. 断线重连通知: 按 run_loop 时长判据(≥30s 推断开 / <30s 累计 3 次推不稳定),
新增 notify_monitor()
4. 占位卡带任务原文回显(truncate 60)
5. 白名单配置引导: 后端分步自助引导 + FeishuPanel hintFeishuWhitelist + i18n
6. 审批决议后追加执行结果: spawn_approval_result_watcher 订阅 AiToolCallCompleted
(匹配 tool_call_id, 60s 超时兜底); 订阅须在 ai_approve 前完成(无订阅者跳过序列化)
停靠(parked)写侧根因解(904):
emit_task_parked(ai/mod.rs) 升级为「写 parked + 发事件 + 通知」三件套——
新增 set_loop_state(conv_id, Some("parked")) fire-and-forget; 8 个业务调用点
(H2 三闸/熔断两路/挂起取消/max_rounds/midstream/fatal + 审批超时) 全经单点。
治「停靠卡继续必报 AI 未在暂停态」(全仓原无写 parked 路径)。
agentic/audit 加固: context_lifecycle/stream_relay/mod 调整 + audit record 完善 +
tool_failure_memory/tool_result_offload/tools/task/settings 同步。
验收: cargo check -p devflow EXIT 0。
This commit is contained in:
@@ -84,28 +84,28 @@
|
||||
:disabled="approving"
|
||||
@click="onAuthorizeOnce">
|
||||
<span v-if="approving" class="ai-tool-btn-spinner" />
|
||||
<svg aria-hidden="true" v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
<span v-else class="ai-tool-btn-icon" v-html="ICON_CHECK" />
|
||||
{{ $t('aiChat.dirAuthOnce') }}
|
||||
</button>
|
||||
<button class="ai-tool-btn ai-tool-btn--session"
|
||||
:disabled="approving"
|
||||
@click="onAuthorizeSession">
|
||||
<span v-if="approving" class="ai-tool-btn-spinner" />
|
||||
<svg aria-hidden="true" v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
<span v-else class="ai-tool-btn-icon" v-html="ICON_CHECK" />
|
||||
{{ $t('aiChat.dirAuthSession') }}
|
||||
</button>
|
||||
<button class="ai-tool-btn ai-tool-btn--always"
|
||||
:disabled="approving"
|
||||
@click="onAuthorizeAlways">
|
||||
<span v-if="approving" class="ai-tool-btn-spinner" />
|
||||
<svg aria-hidden="true" v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
<span v-else class="ai-tool-btn-icon" v-html="ICON_CHECK" />
|
||||
{{ $t('aiChat.dirAuthAlways') }}
|
||||
</button>
|
||||
<button class="ai-tool-btn ai-tool-btn--reject"
|
||||
:disabled="approving"
|
||||
@click="onDeny">
|
||||
<span v-if="approving" class="ai-tool-btn-spinner" />
|
||||
<svg aria-hidden="true" v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
<span v-else class="ai-tool-btn-icon" v-html="ICON_CROSS" />
|
||||
{{ $t('aiChat.dirAuthDeny') }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -114,14 +114,14 @@
|
||||
:disabled="approving"
|
||||
@click="onApprove">
|
||||
<span v-if="approving" class="ai-tool-btn-spinner" />
|
||||
<svg aria-hidden="true" v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
<span v-else class="ai-tool-btn-icon" v-html="ICON_CHECK" />
|
||||
{{ $t('aiTool.approve') }}
|
||||
</button>
|
||||
<button class="ai-tool-btn ai-tool-btn--reject"
|
||||
:disabled="approving"
|
||||
@click="onReject">
|
||||
<span v-if="approving" class="ai-tool-btn-spinner" />
|
||||
<svg aria-hidden="true" v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
<span v-else class="ai-tool-btn-icon" v-html="ICON_CROSS" />
|
||||
{{ $t('aiTool.reject') }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -180,6 +180,10 @@ import type { AiToolCallInfo } from '@/api/types'
|
||||
import ConfirmDialog from './ConfirmDialog.vue'
|
||||
import ToolResultBody from './ToolResultBody.vue'
|
||||
|
||||
// 审批按钮图标 SVG 常量(消除模板 6 处内联重复;v-html 渲染完整 svg 元素)
|
||||
const ICON_CHECK = '<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>'
|
||||
const ICON_CROSS = '<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -511,6 +515,11 @@ const visibleArgs = computed(() => {
|
||||
.ai-tool-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
.ai-tool-btn--approve:disabled:hover { background: var(--df-success-bg); color: var(--df-success); border-color: var(--df-border); }
|
||||
.ai-tool-btn--reject:disabled:hover { background: var(--df-danger-bg); color: var(--df-danger); border-color: var(--df-border); }
|
||||
.ai-tool-btn-icon {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 12px; height: 12px; flex-shrink: 0;
|
||||
}
|
||||
.ai-tool-btn-icon :deep(svg) { display: block; }
|
||||
.ai-tool-btn-spinner {
|
||||
width: 12px; height: 12px; border: 1.5px solid currentColor; border-top-color: transparent;
|
||||
border-radius: 50%; display: inline-block; flex-shrink: 0; animation: aiToolBtnSpin 0.7s linear infinite;
|
||||
|
||||
@@ -43,6 +43,9 @@
|
||||
></textarea>
|
||||
</SettingRow>
|
||||
|
||||
<!-- 白名单配置引导(2026-10-08):用户不知 open_id 从哪来,给出自助获取路径 -->
|
||||
<p class="feishu-hint">{{ $t('settings.hintFeishuWhitelist') }}</p>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary btn-sm" :disabled="saving" @click="save">
|
||||
{{ $t('settings.feishuSave') }}
|
||||
@@ -76,7 +79,7 @@ interface FeishuConfig {
|
||||
enabled: boolean
|
||||
app_id: string
|
||||
app_secret: string
|
||||
/** 逗号分隔 open_id,空 = 放行所有 */
|
||||
/** 逗号分隔 open_id,空 = 放行所有(表单态;落库时拆为数组) */
|
||||
whitelist: string
|
||||
}
|
||||
|
||||
@@ -93,11 +96,17 @@ const saving = ref(false)
|
||||
onMounted(() => {
|
||||
// KV 值由 store 启动 loadAll 统一 JSON.parse 进缓存,此处同步读;
|
||||
// 逐字段回退默认,防历史脏数据缺字段时 v-model 绑到 undefined
|
||||
const saved = appSettings.get<Partial<FeishuConfig>>(FEISHU_CONFIG_KEY, {})
|
||||
const saved = appSettings.get<Record<string, unknown>>(FEISHU_CONFIG_KEY, {})
|
||||
form.enabled = saved.enabled === true
|
||||
form.app_id = typeof saved.app_id === 'string' ? saved.app_id : ''
|
||||
form.app_secret = typeof saved.app_secret === 'string' ? saved.app_secret : ''
|
||||
form.whitelist = typeof saved.whitelist === 'string' ? saved.whitelist : ''
|
||||
// 白名单落库形态为数组(后端契约 Vec<String>);兼容历史字符串脏数据,
|
||||
// 统一转逗号分隔字符串回填表单
|
||||
form.whitelist = Array.isArray(saved.whitelist)
|
||||
? (saved.whitelist as unknown[]).filter((x): x is string => typeof x === 'string').join(',')
|
||||
: typeof saved.whitelist === 'string'
|
||||
? saved.whitelist
|
||||
: ''
|
||||
})
|
||||
|
||||
/** 保存:enabled=true 时 id/secret 必填,不过校验不落库;整体替换写单一 KV 键 */
|
||||
@@ -106,13 +115,19 @@ async function save() {
|
||||
emit('toast', t('settings.feishuValidateIncomplete'), 'warning')
|
||||
return
|
||||
}
|
||||
// 白名单落库为数组(后端 FeishuConfig.whitelist: Vec<String> 契约):
|
||||
// 表单逗号分隔字符串拆分为去空数组,空串 → 空数组(=放行所有)
|
||||
const whitelist = form.whitelist
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0)
|
||||
saving.value = true
|
||||
try {
|
||||
await appSettings.set(FEISHU_CONFIG_KEY, {
|
||||
enabled: form.enabled,
|
||||
app_id: form.app_id.trim(),
|
||||
app_secret: form.app_secret.trim(),
|
||||
whitelist: form.whitelist.trim(),
|
||||
whitelist,
|
||||
})
|
||||
emit('toast', t('settings.feishuSaveOk'), 'success')
|
||||
} catch (e) {
|
||||
@@ -134,5 +149,11 @@ async function save() {
|
||||
resize: vertical;
|
||||
font-family: var(--df-font-mono, monospace);
|
||||
}
|
||||
.feishu-hint {
|
||||
margin: 0 0 4px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: var(--df-text-tertiary, #888);
|
||||
}
|
||||
.actions { display: flex; gap: 8px; margin-top: 8px; }
|
||||
</style>
|
||||
|
||||
@@ -62,10 +62,22 @@ async function loadConversations() {
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
// 恢复上次活跃对话(仅在首次加载、ID 仍有效且当前无活跃对话时)
|
||||
// 恢复上次活跃对话。
|
||||
// 原实现仅「当前无活跃对话」时恢复,但存在启动竞态:AiChat 先 startListener() 注册事件监听,
|
||||
// 后 loadConversations()。注册到本行之间若有带 conversation_id 的后端事件到达(后台会话流式/
|
||||
// AiConvStateChanged/残留事件),useAiEvents.handleEvent 会抢先 state.activeConversationId = convId
|
||||
// 占位(仅赋 id 不加载 messages)→ 本判断 !state.activeConversationId 为 false → switchConversation
|
||||
// 被跳过 → 历史消息不加载 → 界面空白,须手动切走再切回才显示(2026-10-08 实锤)。
|
||||
// 修复:① 无活跃会话 → 恢复 pending(原逻辑);② 活跃会话已存在但历史未加载(竞态占位)
|
||||
// → 强制 switchConversation(force=true 绕过同会话短路)补加载历史。
|
||||
// 判据用 loadMoreCursor.convId(switchConversation 成功提交才置为目标 id,竞态占位时为 null)
|
||||
// 而非 messages.length===0:后者在「占位后恰好收到后台 AiTextDelta 写进 messages」时会漏判。
|
||||
const pending = appSettings.get<string | null>(activeConvKvKey(), null)
|
||||
if (pending && !state.activeConversationId && state.conversations.some(c => c.id === pending)) {
|
||||
await switchConversation(pending)
|
||||
const target = state.activeConversationId ?? pending
|
||||
if (target && state.conversations.some(c => c.id === target)
|
||||
&& loadMoreCursor.convId !== target) {
|
||||
// 历史未加载才补加载:已加载(convId 已指向目标)不重拉,防清空 in-flight 流式文本。
|
||||
await switchConversation(target, true)
|
||||
}
|
||||
} catch (e) {
|
||||
// 网络抖动/IPC 断开时原静默失败,列表可能突空用户不知原因。
|
||||
|
||||
@@ -150,18 +150,37 @@ async function cleanupTerminatedConversation(convId: string, reason: 'AiComplete
|
||||
/**
|
||||
* 登记无人值守停靠卡(幂等,convId 键控覆盖)。lifecycle 主路径与 useAiEvents 后台会话
|
||||
* 分支共用唯一写入口,防止两处字段口径漂移。convId 缺失兜底当前活跃会话(优于丢卡)。
|
||||
*
|
||||
* #906 打扰治理:同会话短时内重复停靠节流。网络抖动期「重发→中断→再弹」会连续触发停靠,
|
||||
* 卡片反复弹/消造成打扰观感。策略:同一 conv 距上次停靠 < PARKED_REPARK_THROTTLE_MS 时,
|
||||
* 若当前已有停靠卡(未被用户操作摘除)→ 保留原卡不刷新(避免重渲染/时间戳跳动);若原卡已被
|
||||
* 用户「继续/停止」摘除(用户已主动处置)→ 仍放行新卡(不能吞掉用户需决策的新终局)。
|
||||
* 节流仅压「未处置的重复弹」,不吞「用户已处置后的真实新停靠」,安全边界清晰。
|
||||
*/
|
||||
const PARKED_REPARK_THROTTLE_MS = 20000
|
||||
// 上次停靠时刻(per-conv,跨摘卡保留):用于判定「用户已处置后短时再停靠」是否属抖动回环。
|
||||
const _lastParkedAt = new Map<string, number>()
|
||||
|
||||
export function recordTaskParked(convId: string | null | undefined, reason: string, stopKind?: string): void {
|
||||
const id = convId || state.activeConversationId
|
||||
if (!id) return
|
||||
const now = Date.now()
|
||||
const existing = pendingTaskParked.get(id)
|
||||
const lastAt = _lastParkedAt.get(id) ?? 0
|
||||
// 节流:卡仍在(未处置)+ 距上次停靠极近 → 保留原卡,不刷新(压抖动期重复弹)。
|
||||
if (existing && now - lastAt < PARKED_REPARK_THROTTLE_MS) {
|
||||
return
|
||||
}
|
||||
_lastParkedAt.set(id, now)
|
||||
pendingTaskParked.set(id, {
|
||||
reason,
|
||||
stopKind: stopKind || undefined,
|
||||
conversationId: id,
|
||||
parkedAt: Date.now(),
|
||||
parkedAt: now,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
/** lifecycle 域:整轮收尾(AiCompleted)与异常中断(AiError)。返回 true=已处理。
|
||||
* 异步说明:cleanup 内含仲裁 IPC(ai_is_generating),故本函数 async;handleEvent 以 void 调用,
|
||||
* 每个终态 case 必须先 await cleanup 拿到仲裁结果再决定下游执行。 */
|
||||
|
||||
@@ -80,6 +80,8 @@ export default {
|
||||
collapse: 'Collapse trace',
|
||||
roundN: 'Round {n}',
|
||||
processOnly: 'Process log',
|
||||
running: 'Running',
|
||||
taskRef: 'Related task: {name}',
|
||||
stepDone: '✓ {n} calls done',
|
||||
stepRunning: 'running {name}',
|
||||
stepPending: '⏳ {n} pending approval',
|
||||
|
||||
@@ -80,6 +80,8 @@ export default {
|
||||
collapse: '收起轨迹',
|
||||
roundN: '第 {n} 轮',
|
||||
processOnly: '过程记录',
|
||||
running: '进行中',
|
||||
taskRef: '关联任务:{name}',
|
||||
stepDone: '✓ {n} 次调用完成',
|
||||
stepRunning: '正在 {name}',
|
||||
stepPending: '⏳ {n} 项待审批',
|
||||
|
||||
Reference in New Issue
Block a user