Dynavera/site/src/views/OnboardingView.vue

1185 lines
41 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 { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons-vue'
import { apiClient, API, isAxiosError } from '../router/api'
import { useOnboardingAgentStore } from '../stores/onboardingAgentStore'
import { useUserStore } from '../stores/userStore'
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 userStore = useUserStore()
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[]>([])
type QuizGradingDetail = {
key?: string
correct?: boolean | string | number
reason?: string
}
type QuizResult = {
score_percentage: number
pass_mark: number
correct_count: number
gradable_count: number
missing_required_keys?: string[]
grading_details?: QuizGradingDetail[]
}
const quizResult = ref<QuizResult | 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 canDeleteFlow = computed(() => Boolean(userStore.isGeneralManager))
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 currentPageBody = computed(() => {
const baseBody = currentPage.value?.body || ''
const sessionState = (session.value as unknown as { state?: Record<string, unknown> } | null)?.state
const overridesRaw = sessionState?.page_overrides
if (!currentPage.value || !overridesRaw || typeof overridesRaw !== 'object') {
return baseBody
}
const override = (overridesRaw as Record<string, unknown>)[currentPage.value.uuid]
return typeof override === 'string' && override.trim() ? override : baseBody
})
const renderedBody = computed(() => {
if (!currentPageBody.value) return ''
return DOMPurify.sanitize(marked.parse(currentPageBody.value) as string)
})
const isAnswerCorrect = (value: unknown) => {
if (typeof value === 'boolean') return value
if (typeof value === 'number') return value === 1
if (typeof value === 'string') return value.trim().toLowerCase() === 'true'
return false
}
const quizQuestionResults = computed(() => {
const details = quizResult.value?.grading_details ?? []
return details.map((detail, index) => {
const key = String(detail.key || `question_${index + 1}`)
const field = currentPage.value?.fields?.find((candidate) => candidate.key === key)
return {
key,
label: field?.label || key,
correct: isAnswerCorrect(detail.correct),
reason: typeof detail.reason === 'string' ? detail.reason : '',
}
})
})
const quizQuestionResultByKey = computed(() => {
const byKey: Record<string, { correct: boolean; reason: string }> = {}
quizQuestionResults.value.forEach((item) => {
byKey[item.key] = { correct: item.correct, reason: item.reason }
})
return byKey
})
const getFieldMarking = (fieldKey: string) => {
return quizQuestionResultByKey.value[fieldKey] ?? null
}
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
if (!canDeleteFlow.value) {
message.error('Only managers can delete onboarding flows.')
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?: QuizResult
}>(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?: QuizResult }>(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
revised_page_body?: string | null
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()
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 and wait for your manager to review your onboarding results.
</Typography.Paragraph>
<div class="completed-actions">
<Button @click="router.push('/organization')">
Return to Organization
</Button>
<Popconfirm
v-if="canDeleteFlow"
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
v-if="canDeleteFlow"
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"
class="white-label"
>
<template #label>
<span class="field-label-inline">
<span>{{ `${fieldIndex + 1}. ${field.label}` }}</span>
<span
v-if="getFieldMarking(field.key)"
class="field-marking-inline"
>
<CheckCircleOutlined
v-if="getFieldMarking(field.key)?.correct"
class="answer-icon correct"
/>
<CloseCircleOutlined
v-else
class="answer-icon incorrect"
/>
<Tag
:color="
getFieldMarking(field.key)?.correct
? 'green'
: 'red'
"
>
{{
getFieldMarking(field.key)?.correct
? 'Correct'
: 'Incorrect'
}}
</Tag>
</span>
</span>
</template>
<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]"
/>
<Typography.Text
v-if="
getFieldMarking(field.key) &&
!getFieldMarking(field.key)?.correct &&
getFieldMarking(field.key)?.reason
"
class="field-marking-reason"
>
{{ getFieldMarking(field.key)?.reason }}
</Typography.Text>
</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>
<template v-if="hasNext">
<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 dont 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>
</template>
</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;
}
.field-label-inline {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.field-marking-inline {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.field-marking-reason {
display: block;
margin-top: 0.4rem;
color: #4b5563;
}
.answer-icon {
font-size: 1rem;
}
.answer-icon.correct {
color: #16a34a;
}
.answer-icon.incorrect {
color: #dc2626;
}
.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>