Files
yawan-workbench/web/src/pages/TrainingMapTiming.tsx
T

134 lines
10 KiB
TypeScript
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.
import { useEffect, useState } from 'react'
import { api, localizeError } from '../api'
import type { ReadingCoverage } from './TrainingMapCoverage'
import './training-map-timing.css'
export type AssessmentMode = 'reuse' | 'full' | 'quick'
export type AssessmentEstimate = {
mode: AssessmentMode; total_chunks: number; total_files: number; estimated_batches: number
parallelism?: number; reusable_chunks?: number; reusable_files?: number; reusable_batches?: number
new_chunks?: number; new_batches?: number
seconds_low: number; seconds_high: number; basis: string
stages: { id: string; label: string; seconds_low: number; seconds_high: number }[]
per_day_seconds_low: number; per_day_seconds_high: number
}
const count = (value?: number) => Math.max(0, Number(value) || 0).toLocaleString('zh-CN')
function duration(seconds: number): string {
const value = Math.max(0, Math.ceil(seconds))
if (value < 60) return `${value} 秒`
if (value < 3600) return `${Math.ceil(value / 60)} 分钟`
const hours = Math.floor(value / 3600), minutes = Math.ceil((value % 3600) / 60)
return minutes === 60 ? `${hours + 1} 小时` : `${hours} 小时${minutes ? ` ${minutes} 分钟` : ''}`
}
export function timeRange(low?: number, high?: number): string {
if (low === undefined || high === undefined || !Number.isFinite(low) || !Number.isFinite(high)) return '正在估算'
const first = duration(low), last = duration(Math.max(low, high))
return first === last ? `约 ${first}` : `约 ${first}~${last}`
}
export function useAssessmentEstimate(ids: number[], mode: AssessmentMode, requestedDays: string, categoryScope: Record<string, number[]> = {}) {
const selection = JSON.stringify({ kb_ids: [...new Set(ids)].sort((a, b) => a - b), mode, kb_category_ids: categoryScope,
requested_days: requestedDays && Number(requestedDays) >= 1 && Number(requestedDays) <= 30 ? Number(requestedDays) : null })
const [result, setResult] = useState<{ key: string; data?: AssessmentEstimate; error?: string } | null>(null)
const valid = ids.length > 0 && Object.values(categoryScope).every(values => values.length > 0)
useEffect(() => {
if (!valid) return
let cancelled = false
let timer: ReturnType<typeof setTimeout> | undefined
const refresh = async () => {
try {
// An estimate is advisory; never let a busy production gateway hold
// the form for the full Cloudflare timeout window.
const data = await Promise.race([
api.post<AssessmentEstimate>('/training-maps/estimate', JSON.parse(selection)),
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('估时服务繁忙,稍后自动重试')), 8000)),
])
if (cancelled) return
setResult({ key: selection, data })
// Automatic background reading can finish while this form stays open.
// Keep the reuse count current without forcing the user to reselect libraries.
if (mode === 'reuse' && (data.new_chunks ?? data.total_chunks - (data.reusable_chunks || 0)) > 0)
timer = setTimeout(refresh, 30000)
} catch (error) {
if (cancelled) return
const message = error instanceof Error && error.message === '估时服务繁忙,稍后自动重试'
? error.message
: localizeError(error)
setResult({ key: selection, error: /524|超时|timeout/i.test(message) ? '估时服务繁忙,稍后自动重试' : message })
}
}
timer = setTimeout(refresh, 350)
return () => { cancelled = true; if (timer) clearTimeout(timer) }
}, [selection, valid])
const current = result?.key === selection ? result : null
return { estimate: current?.data, estimateError: current?.error || '', estimateLoading: valid && !current }
}
export function EstimatePreview({ estimate, loading, error, selected }: {
estimate?: AssessmentEstimate; loading: boolean; error: string; selected: boolean
}) {
return <section className="tm-time-estimate" aria-label="预计完成时间">
<div className="tm-time-heading"><strong>预计检索与方案生成时间</strong><span aria-live="polite">{!selected ? '选择知识库后显示' : loading ? '正在估算…' : estimate ? estimate.total_chunks ? timeRange(estimate.seconds_low, estimate.seconds_high) : '暂无可估算资料' : '暂时无法估算'}</span></div>
{estimate && <>
{estimate.mode !== 'quick' && estimate.total_chunks > 0 && <dl className="tm-estimate-inventory">
{estimate.mode === 'reuse' && <div><dt>预计复用已读</dt><dd>{count(estimate.reusable_files)} 个文件 · {count(estimate.reusable_chunks)} 段</dd></div>}
<div><dt>预计本次新读</dt><dd>{count(estimate.new_chunks ?? estimate.total_chunks)} 段{estimate.new_batches !== undefined ? ` · 约 ${count(estimate.new_batches)} 批` : ''}</dd></div>
{estimate.parallelism !== undefined && <div><dt>阅读并发上限</dt><dd>最多 {count(estimate.parallelism)} 批</dd></div>}
</dl>}
{estimate.mode === 'reuse' && estimate.total_chunks > 0 && <p>{(estimate.reusable_chunks || 0) > 0 ? '以上复用量来自已保存的阅读记录;开始后会核对实际内容,以进度中的复用量为准。' : '当前没有可确认复用的阅读记录,先按全部所选资料需要新读估算;开始后会核对实际可复用内容。'}</p>}
<ol className="tm-time-stages">{estimate.stages.filter(stage => estimate.mode !== 'quick' || stage.id !== 'reading').map(stage => <li key={stage.id}><span>{stage.label}</span><strong>{timeRange(stage.seconds_low, stage.seconds_high)}</strong></li>)}</ol>
<p>{estimate.basis}。</p>
{estimate.total_chunks > 0 && <p>以上估时截止到课程方案生成。确认方案后,每一天导图另需 {timeRange(estimate.per_day_seconds_low, estimate.per_day_seconds_high)}。</p>}
</>}
{error && <p role="status">估时暂不可用:{error}。仍可正常开始评估,页面会自动重试。</p>}
</section>
}
export function AssessmentTiming({ coverage, stopping }: { coverage?: ReadingCoverage; stopping?: boolean }) {
if (!coverage?.mode) return null
const terminal = ['complete', 'failed', 'cancelled'].includes(coverage.stage)
const complete = coverage.stage === 'complete'
const failed = coverage.stage === 'failed' || coverage.stage === 'cancelled'
const percent = complete ? 100 : coverage.progress_percent === undefined ? undefined : Math.min(99, Math.max(0, coverage.progress_percent))
const corpus = coverage.mode !== 'quick'
const reuse = coverage.mode === 'reuse'
const phases = reuse ? ['核对资料与已读记录', '复用与补充阅读', '按主题回查原文', '生成课程方案'] : corpus ? ['扫描资料', '分批阅读与汇总', '生成课程方案'] : ['相关资料检索', '生成课程方案']
const stage = complete ? phases.length : coverage.stage === 'planning' ? phases.length - 1 : coverage.stage === 'rechecking' && reuse ? 2 : ['reading', 'reducing', 'summarizing'].includes(coverage.stage) ? 1 : 0
const reading = ['reading', 'reducing', 'summarizing'].includes(coverage.stage)
const parallelism = coverage.effective_parallelism ?? coverage.parallelism ?? coverage.estimate?.parallelism
const active = coverage.timing?.active_model_calls ?? coverage.active_batches
return <section className={`tm-task-progress${failed ? ' is-paused' : ''}`} aria-label="检索与方案生成进度">
<div className="tm-time-heading"><strong>{complete ? '课程方案评估完成' : failed ? '评估已停止' : stopping ? '正在停止,等待当前批次结束' : '检索与方案生成进度'}</strong><span>{percent === undefined ? (failed ? '已停止' : '处理中') : `${Math.round(percent)}%`}</span></div>
<progress max={100} value={percent ?? (terminal ? 0 : undefined)} aria-label="检索与方案生成整体进度" />
<ol className="tm-live-stages">{phases.map((phase, index) => <li key={phase} className={index < stage ? 'is-complete' : index === stage && !failed ? 'is-current' : ''} aria-current={index === stage && !terminal ? 'step' : undefined}>{index < stage ? '✓ ' : `${index + 1}. `}{phase}</li>)}</ol>
<div className="tm-time-metrics"><span>已用时:{coverage.elapsed_seconds === undefined ? '正在统计' : duration(coverage.elapsed_seconds)}</span>
{!terminal && !stopping && <strong>{coverage.overdue ? '本次处理较久,正在更新预估时间' : `预计剩余:${timeRange(coverage.remaining_seconds_low, coverage.remaining_seconds_high)}`}</strong>}
{complete && <strong>已完成,可查看并确认方案</strong>}
</div>
{!terminal && corpus && reading && parallelism !== undefined && <div className="tm-reading-activity">
<span>{coverage.timing?.active_model_calls !== undefined ? '模型正在处理' : '正在处理'}:{active === undefined ? '正在统计' : `${count(active)} 批`} · 当前并发上限 {count(parallelism)} 批</span>
{coverage.rate_limited && <strong role="status">服务商限流,已自动降低并发</strong>}
{!coverage.rate_limited && active === 0 && <span>{stopping ? '正在收尾' : '正在核对或等待可用阅读名额'}</span>}
</div>}
{!terminal && coverage.stage === 'rechecking' && <p role="status">正在所选范围内查找与本次主题相关的原文,补充到课程评估中。</p>}
{!terminal && !stopping && <p>进度随实际完成的阶段和资料更新;AI 返回方案后才会到 100%。</p>}
</section>
}
export function DayGenerationTiming({ active, label, estimate }: { active: boolean; label: string; estimate?: AssessmentEstimate }) {
const [elapsed, setElapsed] = useState(0)
useEffect(() => {
if (!active) { setElapsed(0); return }
const started = Date.now()
const timer = setInterval(() => setElapsed(Math.floor((Date.now() - started) / 1000)), 1000)
return () => clearInterval(timer)
}, [active, label])
if (!active) return null
return <section className="tm-task-progress" aria-label="每日导图生成进度"><strong>{label}</strong>
<progress aria-label="等待当天导图生成" />
<div className="tm-time-metrics"><span>已用时:{duration(elapsed)}</span><span>{estimate && elapsed > estimate.per_day_seconds_high ? '模型仍在生成,已超过预估时间' : `本天预计耗时:${timeRange(estimate?.per_day_seconds_low, estimate?.per_day_seconds_high)}`}</span></div>
<p>模型正在组织当天内容与衔接关系,完成后显示导图。</p>
</section>
}