kin: KIN-016 Агенты должны уметь говорить 'не могу'. Если агент не может выполнить задачу (нет доступа, не понимает, выходит за компетенцию) — он должен вернуть status: blocked с причиной, а не пытаться угадывать. PM при получении blocked от агента — эскалирует к человеку через GUI (уведомление) и Telegram (когда будет).

This commit is contained in:
Gros Frumos 2026-03-16 09:13:34 +02:00
parent a605e9d110
commit d9172fc17c
35 changed files with 2375 additions and 23 deletions

View file

@ -0,0 +1,127 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { api, type EscalationNotification } from '../api'
const STORAGE_KEY = 'kin_dismissed_escalations'
const notifications = ref<EscalationNotification[]>([])
const showPanel = ref(false)
let pollTimer: ReturnType<typeof setInterval> | null = null
function loadDismissed(): Set<string> {
try {
const raw = localStorage.getItem(STORAGE_KEY)
return new Set(raw ? JSON.parse(raw) : [])
} catch {
return new Set()
}
}
function saveDismissed(ids: Set<string>) {
localStorage.setItem(STORAGE_KEY, JSON.stringify([...ids]))
}
const dismissed = ref<Set<string>>(loadDismissed())
const visible = computed(() =>
notifications.value.filter(n => !dismissed.value.has(n.task_id))
)
async function load() {
try {
notifications.value = await api.notifications()
} catch {
// silent не ломаем layout при недоступном endpoint
}
}
function dismiss(taskId: string) {
dismissed.value = new Set([...dismissed.value, taskId])
saveDismissed(dismissed.value)
if (visible.value.length === 0) showPanel.value = false
}
function dismissAll() {
const newSet = new Set([...dismissed.value, ...visible.value.map(n => n.task_id)])
dismissed.value = newSet
saveDismissed(newSet)
showPanel.value = false
}
function formatTime(iso: string): string {
try {
return new Date(iso).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })
} catch {
return iso
}
}
onMounted(async () => {
await load()
pollTimer = setInterval(load, 10000)
})
onUnmounted(() => {
if (pollTimer) clearInterval(pollTimer)
})
</script>
<template>
<div class="relative">
<!-- Badge-кнопка видна только при наличии активных эскалаций -->
<button
v-if="visible.length > 0"
@click="showPanel = !showPanel"
class="relative flex items-center gap-1.5 px-2.5 py-1 text-xs bg-red-900/50 text-red-400 border border-red-800 rounded hover:bg-red-900 transition-colors"
>
<span class="inline-block w-1.5 h-1.5 bg-red-500 rounded-full animate-pulse"></span>
Эскалации
<span class="ml-0.5 font-bold">{{ visible.length }}</span>
</button>
<!-- Панель уведомлений -->
<div
v-if="showPanel && visible.length > 0"
class="absolute right-0 top-full mt-2 w-96 bg-gray-900 border border-red-900/60 rounded-lg shadow-2xl z-50"
>
<div class="flex items-center justify-between px-4 py-2.5 border-b border-gray-800">
<span class="text-xs font-semibold text-red-400">Эскалации требуется решение</span>
<div class="flex items-center gap-2">
<button
@click="dismissAll"
class="text-xs text-gray-500 hover:text-gray-300"
>Принять все</button>
<button @click="showPanel = false" class="text-gray-500 hover:text-gray-300 text-lg leading-none">&times;</button>
</div>
</div>
<div class="max-h-80 overflow-y-auto divide-y divide-gray-800">
<div
v-for="n in visible"
:key="n.task_id"
class="px-4 py-3"
>
<div class="flex items-start justify-between gap-2">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-1.5 mb-1">
<span class="text-xs font-mono text-red-400 shrink-0">{{ n.task_id }}</span>
<span class="text-xs text-gray-500">·</span>
<span class="text-xs text-orange-400 shrink-0">{{ n.agent_role }}</span>
<span v-if="n.pipeline_step" class="text-xs text-gray-600 truncate">@ {{ n.pipeline_step }}</span>
</div>
<p class="text-xs text-gray-300 leading-snug break-words">{{ n.reason }}</p>
<p class="text-xs text-gray-600 mt-1">{{ formatTime(n.blocked_at) }}</p>
</div>
<button
@click="dismiss(n.task_id)"
class="shrink-0 px-2 py-1 text-xs bg-gray-800 text-gray-400 border border-gray-700 rounded hover:bg-gray-700 hover:text-gray-200"
>Принято</button>
</div>
</div>
</div>
</div>
<!-- Overlay для закрытия панели -->
<div v-if="showPanel" class="fixed inset-0 z-40" @click="showPanel = false"></div>
</div>
</template>