Dynavera/site/src/views/OnboardingView.vue

1171 lines
40 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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 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[]>([])
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 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
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, 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"
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>