1034 lines
35 KiB
Vue
1034 lines
35 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, isAxiosError } from '../router/api'
|
||
import { useOnboardingAgentStore } from '../stores/onboardingAgentStore'
|
||
import type {
|
||
OnboardingFlow,
|
||
OnboardingPage,
|
||
OnboardingSession,
|
||
OnboardingFlowSummary,
|
||
} from '../types/onboarding'
|
||
|
||
import { Marked } from 'marked'
|
||
import DOMPurify from 'dompurify'
|
||
|
||
const marked = new Marked()
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const agentStore = useOnboardingAgentStore()
|
||
|
||
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)
|
||
const visitedPageUuids = ref<string[]>([])
|
||
const quizResult = ref<{
|
||
score_percentage: number
|
||
pass_mark: number
|
||
correct_count: number
|
||
gradable_count: number
|
||
missing_required_keys?: string[]
|
||
} | null>(null)
|
||
const kaQuestion = ref('')
|
||
const kaLoading = ref(false)
|
||
const kaMode = ref<'separate' | 'update_page'>('separate')
|
||
// 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 completedModules = computed<string[]>(() => {
|
||
const state = (session.value as unknown as { state?: Record<string, unknown> } | null)?.state
|
||
const raw = state?.completed_modules
|
||
return Array.isArray(raw) ? raw.map((item) => String(item)) : []
|
||
})
|
||
|
||
const pageHelpByPage = computed<Record<string, Array<{ question: string; answer: string; timestamp: string }>>>(() => {
|
||
const state = (session.value as unknown as { state?: Record<string, unknown> } | null)?.state
|
||
const raw = state?.page_help
|
||
return raw && typeof raw === 'object'
|
||
? (raw as Record<string, Array<{ question: string; answer: string; timestamp: string }>>)
|
||
: {}
|
||
})
|
||
|
||
const currentPageHelp = computed(() => {
|
||
if (!currentPage.value) return []
|
||
return pageHelpByPage.value[currentPage.value.uuid] || []
|
||
})
|
||
|
||
const renderedBody = computed(() => {
|
||
if (!currentPage.value?.body) return ''
|
||
return DOMPurify.sanitize(marked.parse(currentPage.value.body) as string)
|
||
})
|
||
|
||
const getFlowRoleUuid = (flowData: OnboardingFlowSummary): string | undefined => {
|
||
if (typeof flowData.role === 'string') return flowData.role
|
||
return flowData.role?.uuid
|
||
}
|
||
|
||
const getPageIndexByUuid = (pageUuid?: string | null): number => {
|
||
if (!pageUuid) return -1
|
||
return pages.value.findIndex((page) => String(page.uuid) === String(pageUuid))
|
||
}
|
||
|
||
const restorePageProgressFromSession = () => {
|
||
const sessionState = (session.value as unknown as { state?: Record<string, unknown> } | null)?.state
|
||
|
||
const visitedRaw = sessionState?.visited_pages
|
||
visitedPageUuids.value = Array.isArray(visitedRaw)
|
||
? visitedRaw.map((uuid) => String(uuid)).filter((uuid) => getPageIndexByUuid(uuid) >= 0)
|
||
: []
|
||
|
||
const lastPageIndex = getPageIndexByUuid(
|
||
typeof sessionState?.last_page_uuid === 'string' ? sessionState.last_page_uuid : undefined,
|
||
)
|
||
|
||
const visitedMaxIndex = Array.isArray(visitedRaw)
|
||
? visitedRaw.reduce((maxIndex, pageUuid) => {
|
||
const pageIndex = getPageIndexByUuid(String(pageUuid))
|
||
return pageIndex > maxIndex ? pageIndex : maxIndex
|
||
}, -1)
|
||
: -1
|
||
|
||
const completedModulesRaw = sessionState?.completed_modules
|
||
const completedMaxIndex = Array.isArray(completedModulesRaw)
|
||
? completedModulesRaw.reduce((maxIndex, pageUuid) => {
|
||
const pageIndex = getPageIndexByUuid(String(pageUuid))
|
||
return pageIndex > maxIndex ? pageIndex : maxIndex
|
||
}, -1)
|
||
: -1
|
||
|
||
const inferredInProgressIndex =
|
||
completedMaxIndex >= 0 && completedMaxIndex < pages.value.length - 1
|
||
? completedMaxIndex + 1
|
||
: completedMaxIndex
|
||
|
||
const responsesRaw = sessionState?.responses
|
||
const responseMaxIndex = responsesRaw && typeof responsesRaw === 'object'
|
||
? Object.keys(responsesRaw as Record<string, unknown>).reduce((maxIndex, pageUuid) => {
|
||
const pageIndex = getPageIndexByUuid(String(pageUuid))
|
||
return pageIndex > maxIndex ? pageIndex : maxIndex
|
||
}, -1)
|
||
: -1
|
||
|
||
const resumeIndex = Math.max(
|
||
0,
|
||
lastPageIndex,
|
||
visitedMaxIndex,
|
||
responseMaxIndex,
|
||
completedMaxIndex,
|
||
inferredInProgressIndex,
|
||
)
|
||
|
||
if (pages.value.length === 0) {
|
||
currentPageIndex.value = 0
|
||
return
|
||
}
|
||
|
||
currentPageIndex.value = Math.min(resumeIndex, pages.value.length - 1)
|
||
}
|
||
|
||
const retryGeneration = async () => {
|
||
loading.value = true
|
||
generationHandled.value = false
|
||
|
||
try {
|
||
const response = await apiClient.get<OnboardingFlow[]>(API.onboarding.flows.list(), {
|
||
params: { role_uuid: roleId.value },
|
||
})
|
||
|
||
if (response.data && response.data.length > 0) {
|
||
for (const flow of response.data) {
|
||
await apiClient.delete(API.onboarding.flows.byId(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.onboarding.flows.byId(flowDetails.value.uuid))
|
||
|
||
flowDetails.value = null
|
||
session.value = null
|
||
currentPageIndex.value = 0
|
||
visitedPageUuids.value = []
|
||
quizResult.value = null
|
||
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.onboarding.flows.list(), {
|
||
params: { role_uuid: roleId.value },
|
||
})
|
||
|
||
if (response.data && response.data.length > 0) {
|
||
const matchingFlow = response.data.find((item) => getFlowRoleUuid(item) === roleId.value)
|
||
if (!matchingFlow) {
|
||
flowDetails.value = null
|
||
session.value = null
|
||
visitedPageUuids.value = []
|
||
return
|
||
}
|
||
|
||
flowDetails.value = matchingFlow
|
||
|
||
await loadFlow(matchingFlow.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)
|
||
}
|
||
},
|
||
)
|
||
|
||
watch(
|
||
() => roleId.value,
|
||
async () => {
|
||
flowDetails.value = null
|
||
session.value = null
|
||
currentPageIndex.value = 0
|
||
visitedPageUuids.value = []
|
||
quizResult.value = null
|
||
generationHandled.value = false
|
||
isAutoGenerating.value = false
|
||
Object.keys(formState).forEach((k) => delete formState[k])
|
||
agentStore.disconnect()
|
||
agentStore.clearLog()
|
||
await initOnboarding()
|
||
},
|
||
)
|
||
|
||
const loadFlow = async (flowUuid: string) => {
|
||
const response = await apiClient.get<OnboardingFlow>(API.onboarding.flows.byId(flowUuid))
|
||
flowDetails.value = response.data
|
||
const sessionRes = await apiClient.post<OnboardingSession>(
|
||
API.onboarding.flows.startSession(flowUuid),
|
||
)
|
||
session.value = sessionRes.data
|
||
|
||
if (session.value?.status === 'completed') {
|
||
message.info('You have already completed this onboarding.')
|
||
return
|
||
}
|
||
|
||
restorePageProgressFromSession()
|
||
syncVisitedPages()
|
||
hydrateFormState()
|
||
await persistCurrentPageVisit()
|
||
}
|
||
|
||
const hydrateFormState = () => {
|
||
if (!currentPage.value) return
|
||
|
||
const sessionState = (session.value as unknown as { state?: Record<string, unknown> } | null)?.state
|
||
const storedResponsesRaw = sessionState?.responses
|
||
const pageStoredResponses =
|
||
storedResponsesRaw &&
|
||
typeof storedResponsesRaw === 'object' &&
|
||
currentPage.value.uuid in (storedResponsesRaw as Record<string, unknown>)
|
||
? ((storedResponsesRaw as Record<string, unknown>)[
|
||
currentPage.value.uuid
|
||
] as Record<string, unknown>)
|
||
: {}
|
||
|
||
Object.keys(formState).forEach((k) => delete formState[k])
|
||
currentPage.value.fields?.forEach((f) => {
|
||
if (pageStoredResponses && f.key in pageStoredResponses) {
|
||
formState[f.key] = pageStoredResponses[f.key]
|
||
return
|
||
}
|
||
formState[f.key] = f.default_value ?? ''
|
||
})
|
||
}
|
||
|
||
const buildCurrentResponses = () => {
|
||
const response: Record<string, unknown> = {}
|
||
currentPage.value?.fields?.forEach((field) => {
|
||
response[field.key] = formState[field.key]
|
||
})
|
||
return response
|
||
}
|
||
|
||
const syncVisitedPages = () => {
|
||
const nextVisited = new Set<string>(visitedPageUuids.value)
|
||
|
||
const sessionState = (session.value as unknown as { state?: Record<string, unknown> } | null)?.state
|
||
const visitedRaw = sessionState?.visited_pages
|
||
if (Array.isArray(visitedRaw)) {
|
||
visitedRaw.forEach((pageUuid) => {
|
||
nextVisited.add(String(pageUuid))
|
||
})
|
||
}
|
||
|
||
completedModules.value.forEach((pageUuid) => {
|
||
nextVisited.add(String(pageUuid))
|
||
})
|
||
|
||
const storedResponsesRaw = sessionState?.responses
|
||
if (storedResponsesRaw && typeof storedResponsesRaw === 'object') {
|
||
Object.keys(storedResponsesRaw as Record<string, unknown>).forEach((pageUuid) => {
|
||
nextVisited.add(String(pageUuid))
|
||
})
|
||
}
|
||
|
||
const currentUuid = currentPage.value?.uuid
|
||
if (currentUuid) {
|
||
nextVisited.add(String(currentUuid))
|
||
}
|
||
|
||
visitedPageUuids.value = Array.from(nextVisited)
|
||
}
|
||
|
||
const persistCurrentPageVisit = async () => {
|
||
if (!session.value || !currentPage.value || session.value.status === 'completed') return
|
||
|
||
try {
|
||
const response = await apiClient.post<{
|
||
status: string
|
||
session_state?: Record<string, unknown>
|
||
}>(API.onboarding.sessions.interact(session.value.uuid), {
|
||
page_uuid: currentPage.value.uuid,
|
||
})
|
||
|
||
const apiSessionState = response.data?.session_state
|
||
if (apiSessionState && session.value) {
|
||
;(session.value as unknown as { state?: Record<string, unknown> }).state = apiSessionState
|
||
syncVisitedPages()
|
||
}
|
||
} catch {
|
||
// Avoid noisy errors for background navigation sync attempts.
|
||
}
|
||
}
|
||
|
||
const getPageStatus = (page: OnboardingPage, index: number) => {
|
||
if (completedModules.value.includes(page.uuid)) return 'Completed'
|
||
if (index === currentPageIndex.value) return 'In Progress'
|
||
if (visitedPageUuids.value.includes(page.uuid)) return 'In Progress'
|
||
return 'Not Started'
|
||
}
|
||
|
||
const getPageStatusColor = (page: OnboardingPage, index: number) => {
|
||
const status = getPageStatus(page, index)
|
||
if (status === 'Completed') return 'green'
|
||
if (status === 'In Progress') return 'blue'
|
||
return 'default'
|
||
}
|
||
|
||
const canNavigateToPage = (targetIndex: number) => {
|
||
if (targetIndex <= currentPageIndex.value) return true
|
||
for (let index = 0; index < targetIndex; index += 1) {
|
||
const previousPage = pages.value[index]
|
||
if (!previousPage) return false
|
||
if (!completedModules.value.includes(previousPage.uuid)) return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
const jumpToPage = (index: number) => {
|
||
if (!canNavigateToPage(index)) {
|
||
message.warning('Complete required attempts on earlier modules first.')
|
||
return
|
||
}
|
||
currentPageIndex.value = index
|
||
window.scrollTo(0, 0)
|
||
}
|
||
|
||
const goBackPage = () => {
|
||
if (!hasPrev.value) return
|
||
currentPageIndex.value -= 1
|
||
window.scrollTo(0, 0)
|
||
}
|
||
|
||
const onSubmitPage = async () => {
|
||
if (!currentPage.value || !session.value) return
|
||
|
||
try {
|
||
const response = await apiClient.post<{
|
||
status: string
|
||
session_state?: Record<string, unknown>
|
||
}>(API.onboarding.sessions.interact(session.value.uuid), {
|
||
page_uuid: currentPage.value.uuid,
|
||
responses: buildCurrentResponses(),
|
||
})
|
||
|
||
const apiSessionState = response.data?.session_state
|
||
if (apiSessionState && session.value) {
|
||
;(session.value as unknown as { state?: Record<string, unknown> }).state = apiSessionState
|
||
}
|
||
|
||
syncVisitedPages()
|
||
|
||
quizResult.value = null
|
||
|
||
if (hasNext.value) {
|
||
currentPageIndex.value++
|
||
window.scrollTo(0, 0)
|
||
return
|
||
}
|
||
|
||
const completeResponse = await apiClient.post<{
|
||
message: string
|
||
quiz_result?: {
|
||
score_percentage: number
|
||
pass_mark: number
|
||
correct_count: number
|
||
gradable_count: number
|
||
missing_required_keys?: string[]
|
||
}
|
||
}>(API.onboarding.sessions.complete(session.value.uuid))
|
||
|
||
if (completeResponse.data?.quiz_result) {
|
||
quizResult.value = completeResponse.data.quiz_result
|
||
}
|
||
|
||
message.success('Onboarding Finished!')
|
||
router.push('/organization')
|
||
} catch (error: unknown) {
|
||
if (isAxiosError<{ error?: string; quiz_result?: typeof quizResult.value }>(error)) {
|
||
const data = error.response?.data
|
||
if (data?.quiz_result) {
|
||
quizResult.value = data.quiz_result
|
||
message.error(
|
||
`${data.error || 'Final quiz not passed.'} Score: ${data.quiz_result.score_percentage}% (required ${data.quiz_result.pass_mark}%).`,
|
||
)
|
||
return
|
||
}
|
||
}
|
||
message.error('Failed to save progress')
|
||
}
|
||
}
|
||
|
||
const askKnowledgeAgent = async () => {
|
||
if (!session.value || !currentPage.value || !kaQuestion.value.trim()) return
|
||
|
||
kaLoading.value = true
|
||
try {
|
||
const response = await apiClient.post<{
|
||
status: string
|
||
answer: string
|
||
updated_page: boolean
|
||
session_state?: Record<string, unknown>
|
||
}>(API.onboarding.sessions.askKa(session.value.uuid), {
|
||
page_uuid: currentPage.value.uuid,
|
||
message: kaQuestion.value,
|
||
mode: kaMode.value,
|
||
})
|
||
|
||
const apiSessionState = response.data?.session_state
|
||
if (apiSessionState && session.value) {
|
||
;(session.value as unknown as { state?: Record<string, unknown> }).state = apiSessionState
|
||
}
|
||
|
||
syncVisitedPages()
|
||
|
||
if (response.data?.updated_page && flowDetails.value) {
|
||
const flowResponse = await apiClient.get<OnboardingFlow>(
|
||
API.onboarding.flows.byId(flowDetails.value.uuid),
|
||
)
|
||
flowDetails.value = flowResponse.data
|
||
}
|
||
|
||
kaQuestion.value = ''
|
||
} catch {
|
||
message.error('Could not retrieve clarification right now')
|
||
} finally {
|
||
kaLoading.value = false
|
||
}
|
||
}
|
||
|
||
onMounted(() => initOnboarding())
|
||
onUnmounted(() => agentStore.disconnect())
|
||
|
||
watch(
|
||
() => currentPageIndex.value,
|
||
async () => {
|
||
kaQuestion.value = ''
|
||
syncVisitedPages()
|
||
hydrateFormState()
|
||
await persistCurrentPageVisit()
|
||
},
|
||
)
|
||
</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>
|
||
|
||
<template v-else-if="flowDetails">
|
||
<Card v-if="session?.status === 'completed'" class="dark-panel content-card">
|
||
<div 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>
|
||
</Card>
|
||
|
||
<div v-else class="flow-shell">
|
||
<Card class="dark-panel toc-card">
|
||
<aside>
|
||
<Typography.Title :level="5" class="white-text toc-title">
|
||
Contents
|
||
</Typography.Title>
|
||
<div class="toc-list">
|
||
<button
|
||
v-for="(page, index) in pages"
|
||
:key="page.uuid"
|
||
type="button"
|
||
class="toc-item"
|
||
:class="{
|
||
active: index === currentPageIndex,
|
||
blocked: !canNavigateToPage(index),
|
||
}"
|
||
@click="jumpToPage(index)"
|
||
>
|
||
<span class="toc-index">{{ index + 1 }}</span>
|
||
<span class="toc-text">{{ page.title }}</span>
|
||
<Tag :color="getPageStatusColor(page, index)">
|
||
{{ getPageStatus(page, index) }}
|
||
</Tag>
|
||
</button>
|
||
</div>
|
||
</aside>
|
||
</Card>
|
||
|
||
<Card class="dark-panel content-card main-content-card">
|
||
<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: #dbe3ec" />
|
||
|
||
<section class="flow-content" 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: #dbe3ec" />
|
||
|
||
<Form layout="vertical" :model="formState" @finish="onSubmitPage">
|
||
<Form.Item
|
||
v-for="(field, fieldIndex) in currentPage.fields"
|
||
:key="field.uuid"
|
||
:label="`${fieldIndex + 1}. ${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="goBackPage">
|
||
Back
|
||
</Button>
|
||
<Button type="primary" html-type="submit" size="large">
|
||
{{ hasNext ? 'Next Module' : 'Submit Quiz & Complete' }}
|
||
</Button>
|
||
</div>
|
||
|
||
<div v-if="quizResult && !hasNext" class="feedback-box">
|
||
<Typography.Title :level="5" class="white-text">
|
||
Final Quiz Result
|
||
</Typography.Title>
|
||
<Typography.Paragraph
|
||
class="white-text"
|
||
style="opacity: 0.8"
|
||
>
|
||
Score: {{ quizResult.score_percentage }}% | Pass mark:
|
||
{{ quizResult.pass_mark }}% | Correct:
|
||
{{ quizResult.correct_count }}/{{ quizResult.gradable_count }}
|
||
</Typography.Paragraph>
|
||
<Typography.Paragraph
|
||
v-if="quizResult.missing_required_keys?.length"
|
||
class="white-text"
|
||
style="opacity: 0.8"
|
||
>
|
||
Missing required answers:
|
||
{{ quizResult.missing_required_keys.join(', ') }}
|
||
</Typography.Paragraph>
|
||
</div>
|
||
|
||
<Divider dashed style="border-color: #dbe3ec" />
|
||
|
||
<div class="ka-help-box">
|
||
<Typography.Title :level="5" class="white-text" style="margin-bottom: 8px">
|
||
Need clarification?
|
||
</Typography.Title>
|
||
<Typography.Paragraph class="white-text" style="opacity: 0.8">
|
||
Ask the Knowledge Agent to explain this page or refine the page content.
|
||
</Typography.Paragraph>
|
||
<Input.TextArea
|
||
v-model:value="kaQuestion"
|
||
:auto-size="{ minRows: 2, maxRows: 5 }"
|
||
placeholder="Ask what you don’t understand about this module..."
|
||
/>
|
||
<div class="ka-actions">
|
||
<Select
|
||
v-model:value="kaMode"
|
||
:options="[
|
||
{ label: 'Show separate answer below (will not save)', value: 'separate' },
|
||
{ label: 'Update current page content', value: 'update_page' },
|
||
]"
|
||
style="min-width: 280px"
|
||
/>
|
||
<Button
|
||
type="default"
|
||
:loading="kaLoading"
|
||
:disabled="!kaQuestion.trim()"
|
||
@click="askKnowledgeAgent"
|
||
>
|
||
Ask KA
|
||
</Button>
|
||
</div>
|
||
|
||
<div v-if="currentPageHelp.length" class="ka-thread">
|
||
<div
|
||
v-for="(entry, idx) in currentPageHelp"
|
||
:key="`${entry.timestamp}-${idx}`"
|
||
class="ka-thread-item"
|
||
>
|
||
<Typography.Text class="white-text" strong>
|
||
You:
|
||
</Typography.Text>
|
||
<Typography.Paragraph class="white-text" style="opacity: 0.9; margin-bottom: 6px">
|
||
{{ entry.question }}
|
||
</Typography.Paragraph>
|
||
<Typography.Text class="white-text" strong>
|
||
KA:
|
||
</Typography.Text>
|
||
<div class="markdown-body" v-html="DOMPurify.sanitize(marked.parse(entry.answer) as string)"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Form>
|
||
</section>
|
||
</Card>
|
||
</div>
|
||
</template>
|
||
|
||
<Empty v-else-if="!loading" description="Role Context Missing" />
|
||
</Spin>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.page-container {
|
||
max-width: 1280px;
|
||
margin: 2rem auto;
|
||
padding: 0 1rem;
|
||
}
|
||
.dark-panel {
|
||
background: #ffffff;
|
||
border: 1px solid #dbe3ec;
|
||
color: #1f2937;
|
||
}
|
||
.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;
|
||
}
|
||
|
||
.flow-shell {
|
||
display: grid;
|
||
grid-template-columns: 280px minmax(0, 1fr);
|
||
gap: 1rem;
|
||
align-items: start;
|
||
}
|
||
|
||
.toc-card {
|
||
position: sticky;
|
||
top: 1rem;
|
||
align-self: start;
|
||
}
|
||
|
||
.main-content-card {
|
||
width: min(900px, 100%);
|
||
justify-self: center;
|
||
}
|
||
|
||
.toc-title {
|
||
margin-bottom: 0.75rem !important;
|
||
}
|
||
|
||
.toc-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
.toc-item {
|
||
width: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.6rem;
|
||
background: #f8fafc;
|
||
border: 1px solid #dbe3ec;
|
||
border-radius: 8px;
|
||
color: #1f2937;
|
||
padding: 0.5rem 0.6rem;
|
||
cursor: pointer;
|
||
text-align: left;
|
||
}
|
||
|
||
.toc-item.active {
|
||
border-color: #2563eb;
|
||
}
|
||
|
||
.toc-item.blocked {
|
||
opacity: 0.7;
|
||
}
|
||
|
||
.toc-index {
|
||
min-width: 1.5rem;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.toc-text {
|
||
flex: 1;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.flow-content {
|
||
min-width: 0;
|
||
}
|
||
|
||
.white-text {
|
||
color: #1f2937 !important;
|
||
}
|
||
.white-label :deep(.ant-form-item-label > label) {
|
||
color: #1f2937 !important;
|
||
}
|
||
|
||
.orchestrator-logs {
|
||
background: #f8fafc;
|
||
padding: 1.2rem;
|
||
border-radius: 8px;
|
||
height: 300px;
|
||
overflow-y: auto;
|
||
font-family: monospace;
|
||
border: 1px solid #dbe3ec;
|
||
display: flex;
|
||
flex-direction: column-reverse;
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.log-entry {
|
||
margin-bottom: 0.8rem;
|
||
padding-bottom: 0.5rem;
|
||
border-bottom: 1px solid #dbe3ec;
|
||
}
|
||
.log-msg {
|
||
margin-top: 4px;
|
||
}
|
||
.markdown-body {
|
||
line-height: 1.7;
|
||
color: #1f2937;
|
||
}
|
||
.markdown-body :deep(h1),
|
||
.markdown-body :deep(h2),
|
||
.markdown-body :deep(h3),
|
||
.markdown-body :deep(h4) {
|
||
color: #1f2937;
|
||
margin-top: 1rem;
|
||
}
|
||
.markdown-body :deep(p),
|
||
.markdown-body :deep(li) {
|
||
color: #374151;
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
.feedback-box {
|
||
margin-top: 1rem;
|
||
padding: 1rem;
|
||
border: 1px solid #dbe3ec;
|
||
border-radius: 8px;
|
||
background: #f8fafc;
|
||
}
|
||
|
||
.ka-help-box {
|
||
margin-top: 1rem;
|
||
padding: 1rem;
|
||
border: 1px solid #dbe3ec;
|
||
border-radius: 8px;
|
||
background: #f8fafc;
|
||
}
|
||
|
||
.ka-actions {
|
||
margin-top: 0.75rem;
|
||
display: flex;
|
||
gap: 0.75rem;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.ka-thread {
|
||
margin-top: 1rem;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.75rem;
|
||
}
|
||
|
||
.ka-thread-item {
|
||
border: 1px solid #dbe3ec;
|
||
border-radius: 8px;
|
||
padding: 0.75rem;
|
||
background: #ffffff;
|
||
}
|
||
.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: #6b7280 !important;
|
||
}
|
||
:deep(.ant-steps-item-active .ant-steps-item-title) {
|
||
color: #1f2937 !important;
|
||
}
|
||
|
||
@media (max-width: 992px) {
|
||
.flow-shell {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.toc-card {
|
||
position: static;
|
||
}
|
||
}
|
||
</style>
|