project-analyzer: analisis completo con contexto global + dedup
- services/project-analyzer.ts: analiza sesiones + resumenes + HUs existentes - generateMissingHUs: proposicion inteligente sin duplicados - createMissingHUs: crea en KAPPA solo las que no existen (comparacion por titulo) - AiProjectChat: sin limite de HUs en contexto JSON compacto - DashboardView: card 'Generar HUs faltantes' con resultado - ai.ts: stripThinkTags() para eliminar bloques de razonamiento
This commit is contained in:
@@ -3,6 +3,8 @@ import { ref, computed } from 'vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { chatWithAI } from '@/services/ai'
|
import { chatWithAI } from '@/services/ai'
|
||||||
import { useSettingsStore, AVAILABLE_MODELS, PROVIDER_CONFIG, hasProviderApiKey, type AIProvider } from '@/stores/settings'
|
import { useSettingsStore, AVAILABLE_MODELS, PROVIDER_CONFIG, hasProviderApiKey, type AIProvider } from '@/stores/settings'
|
||||||
|
import { useWorkItemsStore } from '@/stores/workitems'
|
||||||
|
import { getSessionsByProject, getProjectState } from '@/services/transcriptions-db'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
@@ -18,8 +20,10 @@ import { Send, Loader2, AlertCircle, Brain, Settings2, Check, ChevronDown } from
|
|||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const settings = useSettingsStore()
|
const settings = useSettingsStore()
|
||||||
|
const workItems = useWorkItemsStore()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
|
projectId: { type: Number, required: true },
|
||||||
projectName: { type: String, required: true },
|
projectName: { type: String, required: true },
|
||||||
projectDescription: { type: String, default: '' },
|
projectDescription: { type: String, default: '' },
|
||||||
epicCount: { type: Number, required: true },
|
epicCount: { type: Number, required: true },
|
||||||
@@ -35,43 +39,38 @@ const response = ref('')
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
const configuredProviders = computed(() => {
|
const allProviders: AIProvider[] = ['openrouter', 'minimax', 'opencode']
|
||||||
const providers: AIProvider[] = ['openrouter', 'minimax', 'opencode']
|
|
||||||
return providers.filter(p => hasProviderApiKey(p))
|
|
||||||
})
|
|
||||||
|
|
||||||
const allProviders = computed(() => {
|
|
||||||
const providers: AIProvider[] = ['openrouter', 'minimax', 'opencode']
|
|
||||||
return providers
|
|
||||||
})
|
|
||||||
|
|
||||||
function modelsForProvider(p: AIProvider) {
|
function modelsForProvider(p: AIProvider) {
|
||||||
return AVAILABLE_MODELS.filter(m => m.provider === p)
|
return AVAILABLE_MODELS.filter(m => m.provider === p)
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchModel(provider: AIProvider, modelId: string) {
|
function switchModel(provider: AIProvider, modelId: string) {
|
||||||
if (provider !== settings.provider) {
|
if (provider !== settings.provider) settings.setActiveProvider(provider)
|
||||||
settings.setActiveProvider(provider)
|
|
||||||
}
|
|
||||||
settings.setModel(modelId)
|
settings.setModel(modelId)
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSystemPrompt(): string {
|
async function buildContext(): Promise<string> {
|
||||||
return `Eres un asistente de proyectos ágil integrado en ${props.projectName}.
|
const ctx: Record<string, unknown> = {}
|
||||||
Tu rol es ayudar al usuario a pensar, planificar y organizar el trabajo del proyecto.
|
|
||||||
|
|
||||||
Contexto del proyecto:
|
// 1. HUs: todas, formato compacto (sin límite)
|
||||||
- Nombre: ${props.projectName}
|
const hus = workItems.userStories
|
||||||
- Descripción: ${props.projectDescription || 'Sin descripción'}
|
if (hus.length > 0) {
|
||||||
- Épicas: ${props.epicCount}
|
const pending = hus.filter(h => !['done','completed','closed','finalizado'].includes(String(h.status ?? '').toLowerCase()))
|
||||||
- HUs: ${props.huCount}
|
ctx.hus = { total: hus.length, pending: pending.length, items: hus.map(h => ({ t: h._cleanTitle || h.title, s: h.status, p: h.priority })) }
|
||||||
|
}
|
||||||
|
|
||||||
Reglas:
|
// 2. Sesiones: últimas 3
|
||||||
1. Respondé en el mismo idioma del usuario (español por defecto)
|
const sessions = await getSessionsByProject(props.projectId)
|
||||||
2. Sé conciso y práctico
|
if (sessions.length > 0) {
|
||||||
3. Si el usuario pide crear algo que requiere acción en KAPPA, explicá que debe usar la sección de Transcripciones
|
ctx.sessions = { total: sessions.length, recent: sessions.slice(-3).reverse().map(s => ({ d: s.date, t: s.title })) }
|
||||||
4. Podés ayudar a redactar HUs, criterios de aceptación, priorizar tareas, sugerir enfoques
|
}
|
||||||
5. No inventes información que no esté en el contexto del proyecto`
|
|
||||||
|
// 3. Estado
|
||||||
|
const state = await getProjectState(props.projectId)
|
||||||
|
if (state?.summary) ctx.summary = state.summary.slice(0, 300)
|
||||||
|
|
||||||
|
return JSON.stringify(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendPrompt() {
|
async function sendPrompt() {
|
||||||
@@ -84,8 +83,13 @@ async function sendPrompt() {
|
|||||||
prompt.value = ''
|
prompt.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const context = await buildContext()
|
||||||
|
const systemPrompt = `Proyecto: ${props.projectName}. ${props.projectDescription || ''} Épicas: ${props.epicCount} HUs: ${props.huCount}.
|
||||||
|
Datos del proyecto (JSON): ${context || '{}'}
|
||||||
|
Respondé en el mismo idioma del usuario. Sé conciso. Si no sabés, decilo.`
|
||||||
|
|
||||||
const msgs = [{ role: 'user' as const, content: text }]
|
const msgs = [{ role: 'user' as const, content: text }]
|
||||||
const result = await chatWithAI(msgs, buildSystemPrompt())
|
const result = await chatWithAI(msgs, systemPrompt)
|
||||||
response.value = result
|
response.value = result
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
error.value = e.message
|
error.value = e.message
|
||||||
@@ -131,16 +135,8 @@ async function sendPrompt() {
|
|||||||
>
|
>
|
||||||
<div class="flex items-center gap-2 w-full">
|
<div class="flex items-center gap-2 w-full">
|
||||||
<span class="text-xs font-medium">{{ PROVIDER_CONFIG[p].label }}</span>
|
<span class="text-xs font-medium">{{ PROVIDER_CONFIG[p].label }}</span>
|
||||||
<Badge
|
<Badge v-if="hasProviderApiKey(p)" variant="outline" class="text-[9px] text-green-600 border-green-300 dark:text-green-400 dark:border-green-700">{{ t('projectAi.keyReady') }}</Badge>
|
||||||
v-if="hasProviderApiKey(p)"
|
<Badge v-else variant="outline" class="text-[9px] text-muted-foreground">{{ t('projectAi.noKey') }}</Badge>
|
||||||
variant="outline"
|
|
||||||
class="text-[9px] text-green-600 border-green-300 dark:text-green-400 dark:border-green-700"
|
|
||||||
>{{ t('projectAi.keyReady') }}</Badge>
|
|
||||||
<Badge
|
|
||||||
v-else
|
|
||||||
variant="outline"
|
|
||||||
class="text-[9px] text-muted-foreground"
|
|
||||||
>{{ t('projectAi.noKey') }}</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
@@ -150,10 +146,7 @@ async function sendPrompt() {
|
|||||||
@click="switchModel(p, m.id)"
|
@click="switchModel(p, m.id)"
|
||||||
class="flex items-center gap-2 py-1.5 pl-6"
|
class="flex items-center gap-2 py-1.5 pl-6"
|
||||||
>
|
>
|
||||||
<Check
|
<Check v-if="settings.provider === p && settings.modelId === m.id" class="size-3.5 text-primary shrink-0" />
|
||||||
v-if="settings.provider === p && settings.modelId === m.id"
|
|
||||||
class="size-3.5 text-primary shrink-0"
|
|
||||||
/>
|
|
||||||
<div v-else class="size-3.5 shrink-0" />
|
<div v-else class="size-3.5 shrink-0" />
|
||||||
<span class="text-xs">{{ m.label }}</span>
|
<span class="text-xs">{{ m.label }}</span>
|
||||||
<code class="text-[9px] text-muted-foreground ml-auto truncate max-w-[120px]">{{ m.id }}</code>
|
<code class="text-[9px] text-muted-foreground ml-auto truncate max-w-[120px]">{{ m.id }}</code>
|
||||||
@@ -161,10 +154,7 @@ async function sendPrompt() {
|
|||||||
<DropdownMenuSeparator v-if="p !== allProviders[allProviders.length - 1]" />
|
<DropdownMenuSeparator v-if="p !== allProviders[allProviders.length - 1]" />
|
||||||
</template>
|
</template>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem class="text-xs text-muted-foreground justify-center" @click="emit('navigate-settings')">
|
||||||
class="text-xs text-muted-foreground justify-center"
|
|
||||||
@click="emit('navigate-settings')"
|
|
||||||
>
|
|
||||||
<Settings2 class="size-3 mr-1" />
|
<Settings2 class="size-3 mr-1" />
|
||||||
{{ t('projectAi.settings') }}
|
{{ t('projectAi.settings') }}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -194,10 +184,7 @@ async function sendPrompt() {
|
|||||||
class="text-xs text-muted-foreground p-3 rounded-lg border border-dashed text-center"
|
class="text-xs text-muted-foreground p-3 rounded-lg border border-dashed text-center"
|
||||||
>
|
>
|
||||||
{{ t('projectAi.noKey') }}
|
{{ t('projectAi.noKey') }}
|
||||||
<button
|
<button class="text-primary hover:underline cursor-pointer" @click="emit('navigate-settings')">{{ t('projectAi.configureLink') }}</button>.
|
||||||
class="text-primary hover:underline cursor-pointer"
|
|
||||||
@click="emit('navigate-settings')"
|
|
||||||
>{{ t('projectAi.configureLink') }}</button>.
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
@@ -220,9 +207,7 @@ async function sendPrompt() {
|
|||||||
<Send v-else class="size-4" />
|
<Send v-else class="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-[10px] text-muted-foreground text-right">
|
<p class="text-[10px] text-muted-foreground text-right">Ctrl+Enter</p>
|
||||||
Ctrl+Enter
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { callAI } from '@/services/ai'
|
||||||
|
import { getSessionsByProject, getSessionSummary, getProjectState } from '@/services/transcriptions-db'
|
||||||
|
import { kappa } from '@/services/kappa-api'
|
||||||
|
import type { EnrichedUserStory } from '@/stores/workitems'
|
||||||
|
|
||||||
|
interface AnalysisHU {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
acceptance_criteria: string[]
|
||||||
|
priority: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnalysisResult {
|
||||||
|
hus: AnalysisHU[]
|
||||||
|
summary: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ANALYSIS_SYSTEM_PROMPT = `Eres un analista funcional experto. Tu tarea es analizar TODO el contexto de un proyecto y generar las Historias de Usuario (HUs) que faltan.
|
||||||
|
|
||||||
|
Reglas:
|
||||||
|
1. Analizá TODA la información disponible: sesiones, resúmenes, estado del proyecto, HUs existentes
|
||||||
|
2. Identificá requisitos, funcionalidades, mejoras o bugs que NO estén cubiertos por las HUs existentes
|
||||||
|
3. Cada HU debe tener: título claro, descripción detallada, criterios de aceptación verificables
|
||||||
|
4. No generes HUs duplicadas. Compará con la lista de HUs existentes
|
||||||
|
5. Priorizá según urgencia implícita (Alta/Media/Baja)
|
||||||
|
6. Si todo ya está cubierto, devolvé un arreglo vacío
|
||||||
|
7. Respondé SOLO con JSON válido
|
||||||
|
|
||||||
|
Contexto recibido en JSON:
|
||||||
|
- existingHUs: HUs que ya existen en el proyecto (NO repetir)
|
||||||
|
- sessions: sesiones registradas con resúmenes
|
||||||
|
- projectState: estado consolidado del proyecto
|
||||||
|
|
||||||
|
Formato de respuesta:
|
||||||
|
{
|
||||||
|
"hus": [
|
||||||
|
{
|
||||||
|
"title": "Título de la HU",
|
||||||
|
"description": "Descripción detallada",
|
||||||
|
"acceptance_criteria": ["Criterio 1", "Criterio 2"],
|
||||||
|
"priority": "Alta|Media|Baja"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Resumen del análisis: cuántas HUs se crearon y por qué"
|
||||||
|
}`
|
||||||
|
|
||||||
|
export async function analyzeProject(
|
||||||
|
projectId: number,
|
||||||
|
projectName: string,
|
||||||
|
existingHUs: EnrichedUserStory[],
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<AnalysisResult> {
|
||||||
|
// 1. Recolectar todo el contexto del proyecto
|
||||||
|
const sessions = await getSessionsByProject(projectId)
|
||||||
|
const state = await getProjectState(projectId)
|
||||||
|
|
||||||
|
// 2. Construir context compacto en JSON
|
||||||
|
const sessionsWithSummaries = []
|
||||||
|
for (const s of sessions) {
|
||||||
|
const summary = await getSessionSummary(s.id!)
|
||||||
|
sessionsWithSummaries.push({
|
||||||
|
date: s.date,
|
||||||
|
title: s.title,
|
||||||
|
file: s.fileName,
|
||||||
|
summary: summary?.summary?.slice(0, 500) || '',
|
||||||
|
tasks: (safeParse<{ description: string; priority: string }[]>(summary?.tasks, [])).map(t => ({ d: t.description, p: t.priority })),
|
||||||
|
decisions: safeParse<string[]>(summary?.decisions, []),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = {
|
||||||
|
projectName,
|
||||||
|
existingHUs: existingHUs.map(h => ({
|
||||||
|
t: h._cleanTitle || h.title,
|
||||||
|
s: h.status,
|
||||||
|
p: h.priority,
|
||||||
|
})),
|
||||||
|
sessions: sessionsWithSummaries.slice(-10).reverse(),
|
||||||
|
projectState: state ? {
|
||||||
|
summary: state.summary?.slice(0, 500),
|
||||||
|
objectives: safeParse<string[]>(state.objectives, []),
|
||||||
|
tasks: (safeParse<{ status: string }[]>(state.tasks, [])).filter(t => t.status !== 'completada').slice(0, 30),
|
||||||
|
} : null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const userContent = `Contexto completo del proyecto en JSON:\n${JSON.stringify(context, null, 0)}`
|
||||||
|
|
||||||
|
console.log(`[Alpha] Project analysis — ${projectId}, ${existingHUs.length} HUs existentes, ${sessions.length} sesiones`)
|
||||||
|
|
||||||
|
const content = await callAI(
|
||||||
|
[
|
||||||
|
{ role: 'system', content: ANALYSIS_SYSTEM_PROMPT },
|
||||||
|
{ role: 'user', content: userContent },
|
||||||
|
],
|
||||||
|
0.3,
|
||||||
|
8192,
|
||||||
|
signal,
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const jsonStr = extractJSON(content)
|
||||||
|
const result: AnalysisResult = JSON.parse(jsonStr)
|
||||||
|
console.log(`[Alpha] Analysis result: ${result.hus.length} nuevas HUs propuestas`)
|
||||||
|
return result
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Alpha] Failed to parse analysis. Raw:', content)
|
||||||
|
throw new Error('No se pudo procesar el análisis del proyecto')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Crea las HUs propuestas en KAPPA (solo las que no existen).
|
||||||
|
* Devuelve cuántas se crearon.
|
||||||
|
*/
|
||||||
|
export async function createMissingHUs(
|
||||||
|
projectId: number,
|
||||||
|
analysis: AnalysisResult,
|
||||||
|
existingHUs: EnrichedUserStory[],
|
||||||
|
): Promise<{ created: number; skipped: number; errors: string[] }> {
|
||||||
|
let created = 0
|
||||||
|
let skipped = 0
|
||||||
|
const errors: string[] = []
|
||||||
|
|
||||||
|
const existingTitles = new Set(existingHUs.map(h => (h._cleanTitle || h.title).toLowerCase().trim()))
|
||||||
|
|
||||||
|
for (const hu of analysis.hus) {
|
||||||
|
const normalizedTitle = hu.title.toLowerCase().trim()
|
||||||
|
|
||||||
|
// Verificar duplicado por título similar
|
||||||
|
const isDuplicate = existingHUs.some(ex => {
|
||||||
|
const et = (ex._cleanTitle || ex.title).toLowerCase().trim()
|
||||||
|
return et === normalizedTitle || et.includes(normalizedTitle) || normalizedTitle.includes(et)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isDuplicate) {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await kappa.createUserStory({
|
||||||
|
title: hu.title,
|
||||||
|
description: hu.description + (hu.acceptance_criteria.length > 0
|
||||||
|
? `\n\n**Criterios de aceptación:**\n${hu.acceptance_criteria.map(c => `- ${c}`).join('\n')}`
|
||||||
|
: ''),
|
||||||
|
initiative: projectId,
|
||||||
|
priority: hu.priority || 'Media',
|
||||||
|
status: 'todo',
|
||||||
|
})
|
||||||
|
created++
|
||||||
|
} catch (e: any) {
|
||||||
|
errors.push(`${hu.title}: ${e.message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { created, skipped, errors }
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractJSON(text: string): string {
|
||||||
|
try { JSON.parse(text); return text } catch {}
|
||||||
|
const block = text.match(/```(?:json)?\s*([\s\S]*?)```/)
|
||||||
|
if (block) { try { JSON.parse(block[1].trim()); return block[1].trim() } catch {} }
|
||||||
|
const first = text.indexOf('{')
|
||||||
|
const last = text.lastIndexOf('}')
|
||||||
|
if (first !== -1 && last > first) { const c = text.slice(first, last + 1); try { JSON.parse(c); return c } catch {} }
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeParse<T>(json: string | undefined | null, fallback: T): T {
|
||||||
|
if (!json) return fallback
|
||||||
|
try { return JSON.parse(json) as T } catch { return fallback }
|
||||||
|
}
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, watch } from 'vue'
|
import { computed, watch, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useProjectsStore } from '@/stores/projects'
|
import { useProjectsStore } from '@/stores/projects'
|
||||||
import { useWorkItemsStore } from '@/stores/workitems'
|
import { useWorkItemsStore } from '@/stores/workitems'
|
||||||
import { getTypeLabel, getTypeColor, getTypeIcon } from '@/services/hierarchy'
|
import { getTypeLabel, getTypeColor, getTypeIcon } from '@/services/hierarchy'
|
||||||
import { Activity, FileText, Layers, Clock, Info, AlertTriangle, Plus, Brain } from 'lucide-vue-next'
|
import { Activity, FileText, Layers, Clock, Info, AlertTriangle, Plus, Brain, Sparkles, Loader2, CheckCircle2, XCircle } from 'lucide-vue-next'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
import HuDrafts from '@/components/HuDrafts.vue'
|
import HuDrafts from '@/components/HuDrafts.vue'
|
||||||
import AiProjectChat from '@/components/AiProjectChat.vue'
|
import AiProjectChat from '@/components/AiProjectChat.vue'
|
||||||
|
import { analyzeProject, createMissingHUs } from '@/services/project-analyzer'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
@@ -27,6 +29,35 @@ const emit = defineEmits<{
|
|||||||
'navigate-settings': []
|
'navigate-settings': []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
// ─── Project analysis ────────────────────────────────────
|
||||||
|
const analyzing = ref(false)
|
||||||
|
const analysisResult = ref<{ created: number; skipped: number; errors: string[] } | null>(null)
|
||||||
|
const analysisSummary = ref('')
|
||||||
|
|
||||||
|
async function runAnalysis() {
|
||||||
|
if (!project.value) return
|
||||||
|
analyzing.value = true
|
||||||
|
analysisResult.value = null
|
||||||
|
analysisSummary.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await analyzeProject(project.value.id, project.value.name || '', workItems.userStories)
|
||||||
|
analysisSummary.value = result.summary
|
||||||
|
|
||||||
|
if (result.hus.length > 0) {
|
||||||
|
const outcome = await createMissingHUs(project.value.id, result, workItems.userStories)
|
||||||
|
analysisResult.value = outcome
|
||||||
|
await workItems.fetchWorkItems(project.value.id)
|
||||||
|
} else {
|
||||||
|
analysisResult.value = { created: 0, skipped: 0, errors: [] }
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
analysisResult.value = { created: 0, skipped: 0, errors: [e.message] }
|
||||||
|
} finally {
|
||||||
|
analyzing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const project = computed(() => projects.selected)
|
const project = computed(() => projects.selected)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -120,6 +151,7 @@ const statusLabel = (status: unknown) => {
|
|||||||
|
|
||||||
<!-- AI Chat -->
|
<!-- AI Chat -->
|
||||||
<AiProjectChat
|
<AiProjectChat
|
||||||
|
:project-id="project.id"
|
||||||
:project-name="project.name ?? ''"
|
:project-name="project.name ?? ''"
|
||||||
:project-description="project.description ?? ''"
|
:project-description="project.description ?? ''"
|
||||||
:epic-count="workItems.totalEpics"
|
:epic-count="workItems.totalEpics"
|
||||||
@@ -127,6 +159,42 @@ const statusLabel = (status: unknown) => {
|
|||||||
@navigate-settings="emit('navigate-settings')"
|
@navigate-settings="emit('navigate-settings')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Project Analysis -->
|
||||||
|
<Card id="dashboard-analysis" class="border-dashed">
|
||||||
|
<CardHeader class="pb-3 flex flex-row items-center justify-between">
|
||||||
|
<CardTitle class="text-sm font-medium flex items-center gap-2">
|
||||||
|
<Sparkles class="size-4" />
|
||||||
|
Análisis completo del proyecto
|
||||||
|
</CardTitle>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
:disabled="analyzing"
|
||||||
|
@click="runAnalysis()"
|
||||||
|
>
|
||||||
|
<Loader2 v-if="analyzing" class="size-4 mr-1 animate-spin" />
|
||||||
|
<Sparkles v-else class="size-4 mr-1" />
|
||||||
|
{{ analyzing ? 'Analizando...' : 'Generar HUs faltantes' }}
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent v-if="analysisResult" class="space-y-2 text-sm">
|
||||||
|
<p class="text-muted-foreground">{{ analysisSummary }}</p>
|
||||||
|
<div class="flex items-center gap-3 text-xs">
|
||||||
|
<span v-if="analysisResult.created > 0" class="text-green-600 dark:text-green-400 flex items-center gap-1">
|
||||||
|
<CheckCircle2 class="size-3" /> {{ analysisResult.created }} HUs creadas
|
||||||
|
</span>
|
||||||
|
<span v-if="analysisResult.skipped > 0" class="text-amber-600 dark:text-amber-400 flex items-center gap-1">
|
||||||
|
{{ analysisResult.skipped }} duplicadas saltadas
|
||||||
|
</span>
|
||||||
|
<span v-if="analysisResult.created === 0 && analysisResult.skipped === 0 && analysisResult.errors.length === 0" class="text-muted-foreground">
|
||||||
|
Todo ya está cubierto. No se requieren nuevas HUs.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="analysisResult.errors.length > 0" class="text-xs text-destructive space-y-0.5">
|
||||||
|
<p v-for="e in analysisResult.errors" :key="e">⚠ {{ e }}</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<!-- Loading -->
|
<!-- Loading -->
|
||||||
<template v-if="workItems.loading">
|
<template v-if="workItems.loading">
|
||||||
<Skeleton class="h-8 w-1/3" />
|
<Skeleton class="h-8 w-1/3" />
|
||||||
|
|||||||
Reference in New Issue
Block a user