511 lines
16 KiB
Vue
511 lines
16 KiB
Vue
<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>
|