kin: KIN-016 Агенты должны уметь говорить 'не могу'. Если агент не может выполнить задачу (нет доступа, не понимает, выходит за компетенцию) — он должен вернуть status: blocked с причиной, а не пытаться угадывать. PM при получении blocked от агента — эскалирует к человеку через GUI (уведомление) и Telegram (когда будет).
This commit is contained in:
parent
a605e9d110
commit
d9172fc17c
35 changed files with 2375 additions and 23 deletions
|
|
@ -1,7 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api, type ProjectDetail, type AuditResult } from '../api'
|
||||
import { api, type ProjectDetail, type AuditResult, type Phase } from '../api'
|
||||
import Badge from '../components/Badge.vue'
|
||||
import Modal from '../components/Modal.vue'
|
||||
|
||||
|
|
@ -12,7 +12,93 @@ const router = useRouter()
|
|||
const project = ref<ProjectDetail | null>(null)
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const activeTab = ref<'tasks' | 'decisions' | 'modules'>('tasks')
|
||||
const activeTab = ref<'tasks' | 'phases' | 'decisions' | 'modules'>('tasks')
|
||||
|
||||
// Phases
|
||||
const phases = ref<Phase[]>([])
|
||||
const phasesLoading = ref(false)
|
||||
const phaseError = ref('')
|
||||
const showReviseModal = ref(false)
|
||||
const revisePhaseId = ref<number | null>(null)
|
||||
const reviseComment = ref('')
|
||||
const reviseError = ref('')
|
||||
const reviseSaving = ref(false)
|
||||
const showRejectModal = ref(false)
|
||||
const rejectPhaseId = ref<number | null>(null)
|
||||
const rejectReason = ref('')
|
||||
const rejectError = ref('')
|
||||
const rejectSaving = ref(false)
|
||||
|
||||
async function loadPhases() {
|
||||
phasesLoading.value = true
|
||||
phaseError.value = ''
|
||||
try {
|
||||
phases.value = await api.getPhases(props.id)
|
||||
} catch (e: any) {
|
||||
phaseError.value = e.message
|
||||
} finally {
|
||||
phasesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function approvePhase(phaseId: number) {
|
||||
try {
|
||||
await api.approvePhase(phaseId)
|
||||
await loadPhases()
|
||||
} catch (e: any) {
|
||||
phaseError.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
function openRevise(phaseId: number) {
|
||||
revisePhaseId.value = phaseId
|
||||
reviseComment.value = ''
|
||||
reviseError.value = ''
|
||||
showReviseModal.value = true
|
||||
}
|
||||
|
||||
async function submitRevise() {
|
||||
if (!reviseComment.value.trim()) { reviseError.value = 'Comment required'; return }
|
||||
reviseSaving.value = true
|
||||
try {
|
||||
await api.revisePhase(revisePhaseId.value!, reviseComment.value)
|
||||
showReviseModal.value = false
|
||||
await loadPhases()
|
||||
} catch (e: any) {
|
||||
reviseError.value = e.message
|
||||
} finally {
|
||||
reviseSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openReject(phaseId: number) {
|
||||
rejectPhaseId.value = phaseId
|
||||
rejectReason.value = ''
|
||||
rejectError.value = ''
|
||||
showRejectModal.value = true
|
||||
}
|
||||
|
||||
async function submitReject() {
|
||||
if (!rejectReason.value.trim()) { rejectError.value = 'Reason required'; return }
|
||||
rejectSaving.value = true
|
||||
try {
|
||||
await api.rejectPhase(rejectPhaseId.value!, rejectReason.value)
|
||||
showRejectModal.value = false
|
||||
await loadPhases()
|
||||
} catch (e: any) {
|
||||
rejectError.value = e.message
|
||||
} finally {
|
||||
rejectSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function phaseStatusColor(s: string) {
|
||||
const m: Record<string, string> = {
|
||||
pending: 'gray', active: 'blue', approved: 'green',
|
||||
rejected: 'red', revising: 'yellow',
|
||||
}
|
||||
return m[s] || 'gray'
|
||||
}
|
||||
|
||||
// Filters
|
||||
const ALL_TASK_STATUSES = ['pending', 'in_progress', 'review', 'blocked', 'decomposed', 'done', 'cancelled']
|
||||
|
|
@ -149,7 +235,12 @@ watch(selectedStatuses, (val) => {
|
|||
router.replace({ query: { ...route.query, status: val.length ? val.join(',') : undefined } })
|
||||
}, { deep: true })
|
||||
|
||||
onMounted(async () => { await load(); loadMode(); loadAutocommit() })
|
||||
onMounted(async () => {
|
||||
await load()
|
||||
loadMode()
|
||||
loadAutocommit()
|
||||
await loadPhases()
|
||||
})
|
||||
|
||||
const taskCategories = computed(() => {
|
||||
if (!project.value) return []
|
||||
|
|
@ -288,16 +379,29 @@ async function addDecision() {
|
|||
<h1 class="text-xl font-bold text-gray-100">{{ project.id }}</h1>
|
||||
<span class="text-gray-400">{{ project.name }}</span>
|
||||
<Badge :text="project.status" :color="project.status === 'active' ? 'green' : 'gray'" />
|
||||
<Badge v-if="project.project_type && project.project_type !== 'development'"
|
||||
:text="project.project_type"
|
||||
:color="project.project_type === 'operations' ? 'orange' : 'green'" />
|
||||
</div>
|
||||
<div class="flex gap-2 flex-wrap mb-2" v-if="project.tech_stack?.length">
|
||||
<Badge v-for="t in project.tech_stack" :key="t" :text="t" color="purple" />
|
||||
</div>
|
||||
<p class="text-xs text-gray-600">{{ project.path }}</p>
|
||||
<!-- Path (development / research) -->
|
||||
<p v-if="project.project_type !== 'operations'" class="text-xs text-gray-600">{{ project.path }}</p>
|
||||
<!-- SSH info (operations) -->
|
||||
<div v-if="project.project_type === 'operations'" class="flex flex-wrap gap-3 text-xs text-gray-600">
|
||||
<span v-if="project.ssh_host">
|
||||
<span class="text-gray-500">SSH:</span>
|
||||
<span class="text-orange-400 ml-1">{{ project.ssh_user || 'root' }}@{{ project.ssh_host }}</span>
|
||||
</span>
|
||||
<span v-if="project.ssh_key_path">key: {{ project.ssh_key_path }}</span>
|
||||
<span v-if="project.ssh_proxy_jump">via: {{ project.ssh_proxy_jump }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex gap-1 mb-4 border-b border-gray-800">
|
||||
<button v-for="tab in (['tasks', 'decisions', 'modules'] as const)" :key="tab"
|
||||
<button v-for="tab in (['tasks', 'phases', 'decisions', 'modules'] as const)" :key="tab"
|
||||
@click="activeTab = tab"
|
||||
class="px-4 py-2 text-sm border-b-2 transition-colors"
|
||||
:class="activeTab === tab
|
||||
|
|
@ -306,6 +410,7 @@ async function addDecision() {
|
|||
{{ tab.charAt(0).toUpperCase() + tab.slice(1) }}
|
||||
<span class="text-xs text-gray-600 ml-1">
|
||||
{{ tab === 'tasks' ? project.tasks.length
|
||||
: tab === 'phases' ? phases.length
|
||||
: tab === 'decisions' ? project.decisions.length
|
||||
: project.modules.length }}
|
||||
</span>
|
||||
|
|
@ -449,6 +554,83 @@ async function addDecision() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Phases Tab -->
|
||||
<div v-if="activeTab === 'phases'">
|
||||
<p v-if="phasesLoading" class="text-gray-500 text-sm">Loading phases...</p>
|
||||
<p v-else-if="phaseError" class="text-red-400 text-sm">{{ phaseError }}</p>
|
||||
<div v-else-if="phases.length === 0" class="text-gray-600 text-sm">
|
||||
No research phases. Use "New Project" to start a research workflow.
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div v-for="ph in phases" :key="ph.id"
|
||||
class="px-4 py-3 border rounded transition-colors"
|
||||
:class="{
|
||||
'border-blue-700 bg-blue-950/20': ph.status === 'active',
|
||||
'border-green-800 bg-green-950/10': ph.status === 'approved',
|
||||
'border-red-800 bg-red-950/10': ph.status === 'rejected',
|
||||
'border-yellow-700 bg-yellow-950/10': ph.status === 'revising',
|
||||
'border-gray-800': ph.status === 'pending',
|
||||
}">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-gray-600 w-5 text-right">{{ ph.phase_order + 1 }}.</span>
|
||||
<Badge :text="ph.status" :color="phaseStatusColor(ph.status)" />
|
||||
<span class="text-sm font-medium text-gray-200">{{ ph.role.replace(/_/g, ' ') }}</span>
|
||||
<span v-if="ph.revise_count" class="text-xs text-yellow-500">(revise ×{{ ph.revise_count }})</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<router-link v-if="ph.task_id"
|
||||
:to="`/task/${ph.task_id}`"
|
||||
class="text-xs text-blue-400 hover:text-blue-300 no-underline">
|
||||
{{ ph.task_id }}
|
||||
</router-link>
|
||||
<template v-if="ph.status === 'active'">
|
||||
<button @click="approvePhase(ph.id)"
|
||||
class="px-2 py-0.5 text-xs bg-green-900/40 text-green-400 border border-green-800 rounded hover:bg-green-900">
|
||||
Approve
|
||||
</button>
|
||||
<button @click="openRevise(ph.id)"
|
||||
class="px-2 py-0.5 text-xs bg-yellow-900/40 text-yellow-400 border border-yellow-800 rounded hover:bg-yellow-900">
|
||||
Revise
|
||||
</button>
|
||||
<button @click="openReject(ph.id)"
|
||||
class="px-2 py-0.5 text-xs bg-red-900/40 text-red-400 border border-red-800 rounded hover:bg-red-900">
|
||||
Reject
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="ph.task?.title" class="mt-1 text-xs text-gray-500 ml-7">{{ ph.task.title }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Revise Modal -->
|
||||
<Modal v-if="showReviseModal" title="Request Revision" @close="showReviseModal = false">
|
||||
<div class="space-y-3">
|
||||
<textarea v-model="reviseComment" placeholder="Что нужно доработать?" rows="4"
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600 resize-none"></textarea>
|
||||
<p v-if="reviseError" class="text-red-400 text-xs">{{ reviseError }}</p>
|
||||
<button @click="submitRevise" :disabled="reviseSaving"
|
||||
class="w-full py-2 bg-yellow-900/50 text-yellow-400 border border-yellow-800 rounded text-sm hover:bg-yellow-900 disabled:opacity-50">
|
||||
{{ reviseSaving ? 'Saving...' : 'Send for Revision' }}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<!-- Reject Modal -->
|
||||
<Modal v-if="showRejectModal" title="Reject Phase" @close="showRejectModal = false">
|
||||
<div class="space-y-3">
|
||||
<textarea v-model="rejectReason" placeholder="Причина отклонения" rows="3"
|
||||
class="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm text-gray-200 placeholder-gray-600 resize-none"></textarea>
|
||||
<p v-if="rejectError" class="text-red-400 text-xs">{{ rejectError }}</p>
|
||||
<button @click="submitReject" :disabled="rejectSaving"
|
||||
class="w-full py-2 bg-red-900/50 text-red-400 border border-red-800 rounded text-sm hover:bg-red-900 disabled:opacity-50">
|
||||
{{ rejectSaving ? 'Saving...' : 'Reject' }}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
<!-- Decisions Tab -->
|
||||
<div v-if="activeTab === 'decisions'">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue