Dynavera/site/src/views/OnboardingView.vue

512 lines
16 KiB
Vue
Raw Normal View History

<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, reactive } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
Card,
Typography,
Button,
Spin,
Select,
Form,
Input,
Switch,
Divider,
message,
Empty,
Steps,
Tag,
Popconfirm,
} from 'ant-design-vue'
import { apiClient, API } from '../router/api'
import { useAgentStore } from '../stores/agentStore'
import type { OnboardingFlow, OnboardingPage, OnboardingSession } from '../types/onboarding'
import { Marked } from 'marked'
import DOMPurify from 'dompurify'
const marked = new Marked()
const route = useRoute()
const router = useRouter()
const agentStore = useAgentStore()
const roleId = computed(() => route.params.roleId as string)
const flowDetails = ref<OnboardingFlow | null>(null)
const session = ref<OnboardingSession | null>(null)
const currentPageIndex = ref(0)
const loading = ref(false)
const isAutoGenerating = ref(false)
const generationHandled = ref(false)
const deletingFlow = ref(false)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const formState = reactive<Record<string, any>>({})
const pages = computed<OnboardingPage[]>(() => flowDetails.value?.pages ?? [])
const currentPage = computed<OnboardingPage | null>(
() => pages.value[currentPageIndex.value] || null,
)
const hasNext = computed(() => currentPageIndex.value < pages.value.length - 1)
const hasPrev = computed(() => currentPageIndex.value > 0)
const isError = computed(() => agentStore.executionStatus === 'failed')
const renderedBody = computed(() => {
if (!currentPage.value?.body) return ''
return DOMPurify.sanitize(marked.parse(currentPage.value.body) as string)
})
type SessionSummary = {
uuid: string
status: string
role?: string | { uuid?: string }
}
const getSessionRoleUuid = (sessionData: SessionSummary): string | undefined => {
if (typeof sessionData.role === 'string') return sessionData.role
return sessionData.role?.uuid
}
const findCompletedSessionForRole = async (): Promise<SessionSummary | null> => {
const sessionRes = await apiClient.get<SessionSummary[]>(API.onboardingSessions())
return (
sessionRes.data.find(
(item) => item.status === 'completed' && getSessionRoleUuid(item) === roleId.value,
) || null
)
}
const retryGeneration = async () => {
loading.value = true
generationHandled.value = false
try {
const response = await apiClient.get<OnboardingFlow[]>(API.onboardingFlows(), {
params: { role: roleId.value },
})
if (response.data && response.data.length > 0) {
for (const flow of response.data) {
await apiClient.delete(API.onboardingFlow(flow.uuid))
}
}
agentStore.clearLog()
agentStore.executionStatus = 'idle'
await startAgenticGeneration()
} catch {
message.error('Failed to reset generation')
} finally {
loading.value = false
}
}
const resetCurrentFlow = async () => {
if (!flowDetails.value || deletingFlow.value) return
deletingFlow.value = true
try {
await apiClient.delete(API.onboardingFlow(flowDetails.value.uuid))
flowDetails.value = null
session.value = null
currentPageIndex.value = 0
Object.keys(formState).forEach((k) => delete formState[k])
generationHandled.value = false
isAutoGenerating.value = false
agentStore.disconnect()
agentStore.clearLog()
message.success('Onboarding flow deleted. Generating a fresh flow...')
await initOnboarding()
} catch {
message.error('Failed to delete onboarding flow')
} finally {
deletingFlow.value = false
}
}
const initOnboarding = async () => {
if (loading.value) return
loading.value = true
try {
const response = await apiClient.get<OnboardingFlow[]>(API.onboardingFlows(), {
params: { role: roleId.value },
})
if (response.data && response.data.length > 0) {
flowDetails.value = response.data[0]
const completedSession = await findCompletedSessionForRole()
if (completedSession) {
session.value = completedSession as unknown as OnboardingSession
currentPageIndex.value = 0
Object.keys(formState).forEach((k) => delete formState[k])
return
}
await loadFlow(response.data[0].uuid)
} else {
if (!generationHandled.value) {
await startAgenticGeneration()
}
}
} catch {
message.error('Could not load onboarding context')
} finally {
loading.value = false
}
}
const startAgenticGeneration = async () => {
isAutoGenerating.value = true
generationHandled.value = false
agentStore.clearLog()
agentStore.connect(roleId.value)
const checkInterval = setInterval(() => {
if (agentStore.isConnected && agentStore.socket) {
agentStore.socket.send(
JSON.stringify({
action: 'start_full_onboarding',
role_uuid: roleId.value,
}),
)
clearInterval(checkInterval)
}
}, 500)
}
watch(
() => agentStore.executionStatus,
async (status) => {
if (status === 'completed' && isAutoGenerating.value && !generationHandled.value) {
generationHandled.value = true
message.success('AI Generation Complete!')
setTimeout(async () => {
isAutoGenerating.value = false
agentStore.disconnect()
await initOnboarding()
}, 1500)
}
},
)
const loadFlow = async (flowUuid: string) => {
const response = await apiClient.get<OnboardingFlow>(API.onboardingFlow(flowUuid))
flowDetails.value = response.data
const sessionRes = await apiClient.post<OnboardingSession>(
API.onboardingFlowStartSession(flowUuid),
)
session.value = sessionRes.data
if (session.value?.status === 'completed') {
message.info('You have already completed this onboarding.')
return
}
hydrateFormState()
}
const hydrateFormState = () => {
if (!currentPage.value) return
Object.keys(formState).forEach((k) => delete formState[k])
currentPage.value.fields?.forEach((f) => {
formState[f.key] = f.default_value ?? ''
})
}
const onSubmitPage = async () => {
if (!currentPage.value || !session.value) return
try {
await apiClient.post(API.onboardingSessionInteract(session.value.uuid), {
page_uuid: currentPage.value.uuid,
responses: formState,
})
if (hasNext.value) {
currentPageIndex.value++
hydrateFormState()
window.scrollTo(0, 0)
} else {
await apiClient.post(API.onboardingSessionComplete(session.value.uuid))
message.success('Onboarding Finished!')
router.push('/organization')
}
} catch {
message.error('Failed to save progress')
}
}
onMounted(() => initOnboarding())
onUnmounted(() => agentStore.disconnect())
</script>
<template>
<div class="page-container">
<Spin :spinning="loading" tip="Loading...">
<Card v-if="isAutoGenerating" class="dark-panel pipeline-card">
<template #title>
<div class="pipeline-header">
<Spin size="small" />
<span class="pulse white-text">AI is Architecting your Onboarding...</span>
</div>
</template>
<div class="orchestrator-logs">
<div v-for="(log, i) in agentStore.eventLog" :key="i" class="log-entry">
<Tag :color="log.type.includes('tool') ? 'green' : 'blue'" class="log-tag">
{{ log.type.toUpperCase() }}
</Tag>
<span class="log-time">
{{ new Date(log.timestamp).toLocaleTimeString() }}
</span>
<div class="log-msg white-text">{{ log.message }}</div>
</div>
<Empty
v-if="!agentStore.eventLog.length"
description="Initializing Pipeline..."
/>
</div>
<div v-if="isError" class="error-retry-zone">
<Typography.Text type="danger" class="error-text">
The Agentic Pipeline encountered an issue. This could be due to a GPU
timeout or a RAG retrieval error.
</Typography.Text>
<Button type="primary" danger @click="retryGeneration">Retry Generation</Button>
</div>
<div class="pipeline-status">
<Steps size="small" :current="agentStore.executionStatus === 'running' ? 1 : 2">
<Steps.Step title="Curriculum" />
<Steps.Step title="Knowledge" />
<Steps.Step title="Assessment" />
</Steps>
</div>
</Card>
<Card v-else-if="flowDetails" class="dark-panel content-card">
<div v-if="session?.status === 'completed'" class="completed-card">
<Typography.Title :level="3" class="white-text">
Onboarding Already Completed
</Typography.Title>
<Typography.Paragraph class="white-text" style="opacity: 0.8">
You have already completed this onboarding flow. You can return to your
organization page, or delete this flow to regenerate a new one.
</Typography.Paragraph>
<div class="completed-actions">
<Button @click="router.push('/organization')">
Return to Organization
</Button>
<Popconfirm
title="Delete this onboarding flow and regenerate it?"
ok-text="Delete"
cancel-text="Cancel"
@confirm="resetCurrentFlow"
>
<Button danger :loading="deletingFlow">Delete Flow</Button>
</Popconfirm>
</div>
</div>
<template v-else>
<div class="flow-header-row">
<Typography.Title :level="2" class="white-text flow-title">
{{ flowDetails.title }}
</Typography.Title>
<Popconfirm
title="Delete this onboarding flow and regenerate it?"
ok-text="Delete"
cancel-text="Cancel"
@confirm="resetCurrentFlow"
>
<Button danger :loading="deletingFlow">Delete Flow</Button>
</Popconfirm>
</div>
<Typography.Paragraph class="white-text" style="opacity: 0.8">
{{ flowDetails.description }}
</Typography.Paragraph>
<Divider style="border-color: #334155" />
<div v-if="currentPage">
<Typography.Title :level="4" class="white-text">
{{ currentPage.title }}
</Typography.Title>
<div class="markdown-body" v-html="renderedBody"></div>
<Divider dashed style="border-color: #334155" />
<Form layout="vertical" :model="formState" @finish="onSubmitPage">
<Form.Item
v-for="field in currentPage.fields"
:key="field.uuid"
:label="field.label"
class="white-label"
>
<Input
v-if="field.field_type === 'text'"
v-model:value="formState[field.key]"
/>
<Input.TextArea
v-else-if="field.field_type === 'textarea'"
v-model:value="formState[field.key]"
/>
<Select
v-else-if="field.field_type === 'select'"
v-model:value="formState[field.key]"
:options="
field.options?.map((o) => ({
label: String(o),
value: String(o),
}))
"
/>
<Switch
v-else-if="field.field_type === 'boolean'"
v-model:checked="formState[field.key]"
/>
</Form.Item>
<div class="form-actions">
<Button :disabled="!hasPrev" @click="currentPageIndex--">
Back
</Button>
<Button type="primary" html-type="submit" size="large">
{{ hasNext ? 'Next Module' : 'Complete Onboarding' }}
</Button>
</div>
</Form>
</div>
</template>
</Card>
<Empty v-else-if="!loading" description="Role Context Missing" />
</Spin>
</div>
</template>
<style scoped>
.page-container {
max-width: 900px;
margin: 2rem auto;
padding: 0 1rem;
}
.dark-panel {
background: #0f172a;
border: 1px solid #1e293b;
color: #f1f5f9;
}
.pipeline-header {
display: flex;
align-items: center;
gap: 1rem;
}
.flow-header-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.flow-title {
margin: 0 !important;
}
.completed-card {
padding: 0.5rem 0;
}
.completed-actions {
display: flex;
gap: 0.75rem;
margin-top: 1rem;
}
.white-text {
color: #ffffff !important;
}
.white-label :deep(.ant-form-item-label > label) {
color: #ffffff !important;
}
.orchestrator-logs {
background: #020617;
padding: 1.2rem;
border-radius: 8px;
height: 300px;
overflow-y: auto;
font-family: monospace;
border: 1px solid #334155;
display: flex;
flex-direction: column-reverse;
margin-bottom: 1rem;
}
.log-entry {
margin-bottom: 0.8rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid #1e293b;
}
.log-msg {
margin-top: 4px;
}
.markdown-body {
line-height: 1.7;
color: #e2e8f0;
}
.markdown-body :deep(h1),
.markdown-body :deep(h2),
.markdown-body :deep(h3),
.markdown-body :deep(h4) {
color: #ffffff;
margin-top: 1rem;
}
.markdown-body :deep(p),
.markdown-body :deep(li) {
color: #e2e8f0;
}
.error-retry-zone {
margin-bottom: 1.5rem;
padding: 1.5rem;
background: rgba(255, 77, 79, 0.15);
border: 1px solid #ff4d4f;
border-radius: 8px;
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
position: relative;
z-index: 10;
}
.error-text {
color: #ff7875 !important;
text-align: center;
font-weight: 500;
}
.form-actions {
display: flex;
justify-content: space-between;
margin-top: 2rem;
}
.pulse {
animation: pulse 2s infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
:deep(.ant-steps-item-title),
:deep(.ant-steps-item-description) {
color: #94a3b8 !important;
}
:deep(.ant-steps-item-active .ant-steps-item-title) {
color: #ffffff !important;
}
</style>