update the cv modal inference proxy server with optimization

This commit is contained in:
DatTT127
2026-07-15 23:24:34 +07:00
parent 3fbbca1eaa
commit 1f5e71b46a
51 changed files with 2067 additions and 245 deletions

View File

@@ -0,0 +1,34 @@
# Dependencies
node_modules/
# Build output
dist/
build/
# Vite
.vite/
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
# Environment files (migrated to config/frontend.config.yaml)
.env
.env.*
!.env.example
# Test coverage
coverage/
# Misc
*.local

View File

@@ -0,0 +1,13 @@
# Frontend application configuration
# This is the single source of truth for frontend feature flags and URLs.
# Edit this file directly instead of using .env / .env.development.
VITE_USE_BACKEND_SEGMENTATION: "true"
VITE_SEGMENT_API_BASE: ""
VITE_USE_CV_CELERY: "false"
VITE_API_BASE_URL: ""
VITE_CLINICAL_CHAT_USE_LLM: "true"
VITE_CLINICAL_CHAT_MOCK_TOOLS: "true"
VITE_OLLAMA_CHAT_URL: "/api/ollama-chat/api/chat"
VITE_OLLAMA_MODEL: "gemma4:e4b"
VITE_MODAL_OLLAMA_TARGET: "https://dtj-tran--ollama-gemma4-e4b-ollamaserver-web.modal.run"

View File

@@ -27,7 +27,7 @@ export default function CalibrationControls({ config, onChange }: CalibrationCon
return (
<div className="cal-ctrl">
<div className="cal-ctrl__header">
<span className="cal-ctrl__title">Điều chỉnh nhiệt đ (T)</span>
<span className="cal-ctrl__title">Điều chỉnh đ chắc chắn (T)</span>
<span className="cal-ctrl__hint tnum">T = {config.temperature.toFixed(2)}</span>
</div>
<CalibrationMetricHelp layout="block" />

View File

@@ -32,10 +32,17 @@ const MODEL_LOAD_COPY: Record<
'installing-gemma': {
title: 'Đang cài đặt Gemma 4 E2B về máy…',
subtitle:
'Mô hình trò chuyện chính (~2 GB). Nếu bị gián đoạn, lần mở sau sẽ tiếp tục từ chỗ đã tải.',
'Mô hình trò chuyện chính (~1.87 GB). Nếu bị gián đoạn, lần mở sau sẽ tiếp tục từ chỗ đã tải.',
ariaLabel: 'Đang cài đặt Gemma 4 E2B',
composerPlaceholder: 'Đang cài đặt Gemma 4 E2B, vui lòng đợi…',
},
'resuming-gemma': {
title: 'Đang tiếp tục tải Gemma 4 E2B…',
subtitle:
'Lần tải trước bị gián đoạn — đang tiếp tục từ chỗ đã tải, không tải lại từ đầu.',
ariaLabel: 'Đang tiếp tục tải Gemma 4 E2B',
composerPlaceholder: 'Đang tiếp tục tải Gemma 4 E2B, vui lòng đợi…',
},
// 'installing-qwen': { ... },
'loading-gemma': {
title: 'Đang nạp Gemma 4 E2B…',
@@ -78,6 +85,7 @@ export default function ClinicalChatPanel({
modelLoadPhase,
modelLoadProgress,
modelInstallTransferLabel,
modelLoadStalled,
modelLoadFading,
sendMessage,
stopGeneration,
@@ -150,8 +158,17 @@ export default function ClinicalChatPanel({
const showLoadBubble = isModelLoading && modelLoadPhase !== null;
const loadCopy = modelLoadPhase ? MODEL_LOAD_COPY[modelLoadPhase] : null;
const progressPercent = Math.min(100, Math.max(0, Math.round(modelLoadProgress)));
const installProgressLabel =
modelLoadPhase && isModelInstallPhase(modelLoadPhase) && modelInstallTransferLabel
const isInstallStalled =
modelLoadStalled && modelLoadPhase !== null && isModelInstallPhase(modelLoadPhase);
const loadTitle = isInstallStalled ? 'Tải Gemma bị gián đoạn' : loadCopy?.title;
const loadSubtitle = isInstallStalled
? `Mất kết nối — hiện không tải được. Đang thử lại tự động${
modelInstallTransferLabel ? ` · đã lưu ${modelInstallTransferLabel}` : ''
}. Có thể tải lại trang để tiếp tục từ chỗ đã tải.`
: loadCopy?.subtitle;
const installProgressLabel = isInstallStalled
? 'Gián đoạn'
: modelLoadPhase && isModelInstallPhase(modelLoadPhase) && modelInstallTransferLabel
? modelInstallTransferLabel
: `${progressPercent}%`;
const activeModeMeta = getInferenceModeMeta(inferenceMode);
@@ -340,20 +357,37 @@ export default function ClinicalChatPanel({
aria-hidden
/>
<div
className={`clinical-chat__load-bubble clinical-chat__load-bubble--${modelLoadPhase} ${modelLoadFading ? 'clinical-chat__load-bubble--fade-out' : ''}`}
className={`clinical-chat__load-bubble clinical-chat__load-bubble--${modelLoadPhase} ${isInstallStalled ? 'clinical-chat__load-bubble--stalled' : ''} ${modelLoadFading ? 'clinical-chat__load-bubble--fade-out' : ''}`}
role="status"
aria-live="polite"
aria-label={
modelLoadPhase && isModelInstallPhase(modelLoadPhase) && modelInstallTransferLabel
? `${loadCopy.ariaLabel}, ${modelInstallTransferLabel}`
: `${loadCopy.ariaLabel}, ${progressPercent} phần trăm`
isInstallStalled
? `${loadCopy.ariaLabel} bị gián đoạn, đang thử lại`
: modelLoadPhase && isModelInstallPhase(modelLoadPhase) && modelInstallTransferLabel
? `${loadCopy.ariaLabel}, ${modelInstallTransferLabel}`
: `${loadCopy.ariaLabel}, ${progressPercent} phần trăm`
}
>
<div
className={`clinical-chat__load-icon clinical-chat__load-icon--${modelLoadPhase}`}
className={`clinical-chat__load-icon clinical-chat__load-icon--${modelLoadPhase} ${isInstallStalled ? 'clinical-chat__load-icon--stalled' : ''}`}
aria-hidden
>
{modelLoadPhase && isModelInstallPhase(modelLoadPhase) ? (
{isInstallStalled ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path
d="M12 8v5M12 16.5v.5"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
/>
<path
d="M10.3 3.9 2.4 18a2 2 0 0 0 1.7 3h15.8a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
</svg>
) : modelLoadPhase && isModelInstallPhase(modelLoadPhase) ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path
d="M12 3v3M12 18v3M4.22 4.22l2.12 2.12M17.66 17.66l2.12 2.12M3 12h3M18 12h3M4.22 19.78l2.12-2.12M17.66 6.34l2.12-2.12"
@@ -373,16 +407,16 @@ export default function ClinicalChatPanel({
</svg>
)}
</div>
<p className="clinical-chat__load-title">{loadCopy.title}</p>
<p className="clinical-chat__load-subtitle">{loadCopy.subtitle}</p>
<p className="clinical-chat__load-title">{loadTitle}</p>
<p className="clinical-chat__load-subtitle">{loadSubtitle}</p>
<div className="clinical-chat__load-progress-row">
<div className="clinical-chat__load-progress-track">
<div
className={`clinical-chat__load-progress-fill clinical-chat__load-progress-fill--${modelLoadPhase}`}
className={`clinical-chat__load-progress-fill clinical-chat__load-progress-fill--${modelLoadPhase} ${isInstallStalled ? 'clinical-chat__load-progress-fill--stalled' : ''}`}
style={{ width: `${progressPercent}%` }}
/>
</div>
<span className="clinical-chat__load-progress-label tnum">{installProgressLabel}</span>
<span className={`clinical-chat__load-progress-label tnum ${isInstallStalled ? 'clinical-chat__load-progress-label--stalled' : ''}`}>{installProgressLabel}</span>
</div>
</div>
</>
@@ -781,12 +815,43 @@ const styles = `
color: var(--color-secondary);
}
.clinical-chat__load-icon--installing-gemma,
.clinical-chat__load-icon--resuming-gemma,
.clinical-chat__load-icon--installing-qwen {
animation: clinical-chat-spin 2.4s linear infinite;
}
.clinical-chat__load-icon--installing {
animation: clinical-chat-spin 2.4s linear infinite;
}
/* Stalled: stop the spin so a frozen download never looks like active progress. */
.clinical-chat__load-bubble--stalled {
border-color: rgba(180, 120, 40, 0.4);
}
.clinical-chat__load-icon--stalled {
animation: clinical-chat-stall-pulse 1.6s ease-in-out infinite;
background: rgba(180, 120, 40, 0.14);
color: #9a6b1f;
}
@keyframes clinical-chat-stall-pulse {
0%, 100% { opacity: 0.55; }
50% { opacity: 1; }
}
.clinical-chat__load-progress-fill--stalled {
background: repeating-linear-gradient(
45deg,
rgba(180, 120, 40, 0.55),
rgba(180, 120, 40, 0.55) 6px,
rgba(180, 120, 40, 0.3) 6px,
rgba(180, 120, 40, 0.3) 12px
);
animation: clinical-chat-stall-stripes 0.8s linear infinite;
}
@keyframes clinical-chat-stall-stripes {
from { background-position: 0 0; }
to { background-position: 17px 0; }
}
.clinical-chat__load-progress-label--stalled {
color: #9a6b1f;
}
@keyframes clinical-chat-pulse-icon {
0%, 100% { opacity: 0.55; transform: scale(0.94); }
50% { opacity: 1; transform: scale(1); }

View File

@@ -1,4 +1,4 @@
import { memo, useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
import { memo, useEffect, useId, useRef, useState } from 'react';
import StreamingPlainText from '../atoms/StreamingPlainText';
import { streamTargetKey } from '../../lib/llm/clinicalChatStreamRegistry';
@@ -18,27 +18,15 @@ function ClinicalChatThought({
thoughtStreaming = false,
}: ClinicalChatThoughtProps) {
const panelId = useId();
const wasThoughtStreamingRef = useRef(thoughtStreaming);
const userToggledRef = useRef(false);
const [expanded, setExpanded] = useState(true);
// Collapsed by default — keeps the clinical answer prominent; click to expand.
const [expanded, setExpanded] = useState(false);
useEffect(() => {
setExpanded(true);
setExpanded(false);
userToggledRef.current = false;
wasThoughtStreamingRef.current = false;
}, [messageId]);
useLayoutEffect(() => {
if (thoughtStreaming) {
if (!userToggledRef.current) {
setExpanded(true);
}
} else if (wasThoughtStreamingRef.current && !thoughtStreaming && !userToggledRef.current) {
setExpanded(false);
}
wasThoughtStreamingRef.current = thoughtStreaming;
}, [thoughtStreaming]);
if (!content.trim() && !thoughtStreaming) {
return null;
}
@@ -49,7 +37,8 @@ function ClinicalChatThought({
};
const label = thoughtStreaming ? 'Đang suy luận' : 'Suy luận';
const preview = !thoughtStreaming && !expanded ? content.replace(/\s+/g, ' ').trim().slice(0, 100) : '';
const preview =
!expanded && !thoughtStreaming ? content.replace(/\s+/g, ' ').trim().slice(0, 100) : '';
return (
<div
@@ -77,15 +66,15 @@ function ClinicalChatThought({
{!expanded && preview ? (
<p className="clinical-chat__thought-preview">{preview}</p>
) : null}
{expanded ? (
<div id={panelId} className="clinical-chat__thought-body">
<StreamingPlainText
text={content}
streamTargetKey={streamTargetKey(messageId, 'thought')}
className="clinical-chat__thought-md chat-md__plain"
/>
</div>
) : null}
{/* Body stays mounted even when collapsed so the imperative stream target keeps
receiving tokens; visibility is toggled via the hidden attribute. */}
<div id={panelId} className="clinical-chat__thought-body" hidden={!expanded}>
<StreamingPlainText
text={content}
streamTargetKey={streamTargetKey(messageId, 'thought')}
className="clinical-chat__thought-md chat-md__plain"
/>
</div>
</div>
);
}

View File

@@ -48,32 +48,35 @@ export default function RecordingModeSelector({ value, onChange, disabled }: Rec
padding: 0;
}
.recording-mode-selector legend {
font-size: 11px;
font-weight: 600;
color: #94a3b8;
margin-bottom: 6px;
font-size: 13px;
font-weight: 700;
color: #e2e8f0;
margin-bottom: 8px;
}
.recording-mode-selector__options {
display: flex;
flex-direction: column;
gap: 4px;
gap: 8px;
}
.recording-mode-selector__option {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #e2e8f0;
gap: 10px;
font-size: 14px;
font-weight: 500;
color: #f1f5f9;
cursor: pointer;
}
.recording-mode-selector__option input {
width: 16px;
height: 16px;
accent-color: #76c8b1;
}
.recording-mode-selector__hint {
margin: 6px 0 0;
font-size: 11px;
line-height: 1.45;
color: #64748b;
margin: 8px 0 0;
font-size: 12.5px;
line-height: 1.5;
color: #cbd5e1;
}
.recording-mode-selector:disabled .recording-mode-selector__option {
opacity: 0.55;

View File

@@ -71,7 +71,7 @@ export default function SeverityBadge({
<div className="severity-panel">
{severityLoading ? (
<div className="severity-panel__block glass">
<span className="severity-panel__eyebrow">Viêm màng hoạt dịch đ nặng (từ phân đoạn)</span>
<span className="severity-panel__eyebrow">Viêm màng hoạt dịch đ nặng (từ nh phân đoạn)</span>
<p className="severity-panel__pending">Đang chờ kết quả đ nặng từ phân đoạn</p>
</div>
) : grade != null && gradeLabel ? (
@@ -80,7 +80,7 @@ export default function SeverityBadge({
{grade}
</span>
<div>
<span className="severity-badge__label">Viêm màng hoạt dịch đ nặng (từ phân đoạn)</span>
<span className="severity-badge__label">Viêm màng hoạt dịch đ nặng (từ nh phân đoạn)</span>
<strong>{gradeLabel}</strong>
{severity?.description && (
<p className="severity-panel__desc">{severity.description}</p>

View File

@@ -34,6 +34,7 @@ interface DiagnosticCanvasProps {
patientMrn?: string;
patientId?: string;
scanFrames?: ScanFrame[];
useCelery?: boolean;
}
export default function DiagnosticCanvas({
@@ -54,6 +55,7 @@ export default function DiagnosticCanvas({
patientMrn,
patientId,
scanFrames: scanFramesProp,
useCelery,
}: DiagnosticCanvasProps) {
const activeFrames =
(scanFramesProp && scanFramesProp.length > 0)
@@ -146,7 +148,7 @@ export default function DiagnosticCanvas({
const lockFrameNav = isSingleFrameNavLocked;
const { overlaySrc, interpretation, angleClassification, inflammationClassification, synovitisSeverity, isLoading, isSegmentationLoading, error, source, retry } =
useSegmentationOverlay(frame.id, frame.src, framesForCanvas, patientMrn);
useSegmentationOverlay(frame.id, frame.src, framesForCanvas, patientMrn, useCelery);
useEffect(() => {
applyFrameIndex(0);
@@ -360,13 +362,6 @@ export default function DiagnosticCanvas({
{showMask && (
<SegmentationOverlay overlaySrc={overlaySrc} isLoading={isLoading} />
)}
{showMask && error && !isLoading && (
<MlServiceErrorPanel
error={error}
frameLabel={frameLabel}
onRetry={source === 'backend' ? retry : undefined}
/>
)}
<canvas
ref={canvasRef}
className="diagnostic-canvas__annotation-canvas"
@@ -377,6 +372,13 @@ export default function DiagnosticCanvas({
{...drawHandlers}
/>
</div>
{showMask && error && !isLoading && (
<MlServiceErrorPanel
error={error}
frameLabel={frameLabel}
onRetry={source === 'backend' ? retry : undefined}
/>
)}
{closedLoopPrompt && closedLoopPromptViewportAnchor && (
<ClosedLoopPrompt
anchor={closedLoopPromptViewportAnchor}

View File

@@ -249,7 +249,7 @@ export default function ReviewDiagnosticSessionPanel({
return (
<div className="review-session">
<header className="review-session__header">
<h2 className="review-session__title">Xem lại phiên chẩn đoán</h2>
<h2 className="review-session__title">XEM LẠI PHIÊN CHUẨN ĐOÁN</h2>
<RecordingModeSelector
value={lifecycle.recordingMode}
onChange={lifecycle.setRecordingMode}
@@ -572,7 +572,7 @@ export default function ReviewDiagnosticSessionPanel({
padding-bottom: 8px;
}
.review-session__title {
margin: 0;
margin: 0 0 16px;
font-size: 15px;
font-weight: 700;
letter-spacing: 0.02em;

View File

@@ -155,7 +155,7 @@ export default function SideNavBar({
<>
<aside className="side-nav glass-elevated">
<section className="side-nav__section">
<h3>Đ xuất AI</h3>
<h3>Đ xuất của AI</h3>
{hasCalibratableOutput && (
<CalibrationControls config={userConfig} onChange={setUserConfig} />

View File

@@ -153,9 +153,11 @@ export default function WorkspaceShell({ zoneA, zoneB }: WorkspaceShellProps) {
<style>{`
.workspace-shell {
display: flex;
flex: 1;
flex: 0 0 calc(100dvh - var(--topbar-h) - var(--bottombar-h));
height: calc(100dvh - var(--topbar-h) - var(--bottombar-h));
min-height: 0;
padding: var(--space-md);
overflow: hidden;
user-select: ${isDragging ? 'none' : 'auto'};
}
.workspace-shell--dragging {
@@ -164,8 +166,10 @@ export default function WorkspaceShell({ zoneA, zoneB }: WorkspaceShellProps) {
.workspace-shell__zone-a {
flex: 0 0 var(--workspace-zone-a-pct);
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
transition: flex-basis 0.05s linear;
}
.workspace-shell--dragging .workspace-shell__zone-a {
@@ -174,8 +178,10 @@ export default function WorkspaceShell({ zoneA, zoneB }: WorkspaceShellProps) {
.workspace-shell__zone-b {
flex: 1 1 0;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
font-size: calc(1rem * var(--workspace-panel-scale, 1));
}
.workspace-shell__divider {
@@ -246,13 +252,17 @@ export default function WorkspaceShell({ zoneA, zoneB }: WorkspaceShellProps) {
}
@media (max-width: 1024px) {
.workspace-shell {
flex: 1 1 auto;
height: auto;
flex-direction: column;
gap: var(--space-md);
overflow: visible;
}
.workspace-shell__zone-a,
.workspace-shell__zone-b {
flex: 1 1 auto;
font-size: 1rem;
overflow: visible;
}
}
`}</style>

View File

@@ -27,7 +27,7 @@ export const CALIBRATION_METRIC_HELP_KEY_POINTS: readonly CalibrationKeyPoint[]
{
title: 'Cơ chế bóp méo của AI hiện đại',
body:
'Các mạng càng sâu và thông minh thì xu hướng phân tách các điểm thô càng xa (ví dụ 20 điểm và 1 điểm). Khi quy đổi, khoảng cách quá lớn này bị ép thành xác suất cực đoan như 99,9% — hiện tượng “quá tự tin” (over-confidence).',
'Các mô hình AI càng phức tạp (kích thuớc lớn & sâu) và thông minh thì xu hướng phân tách các điểm thô càng xa (ví dụ 20 điểm và 1 điểm). Khi quy đổi, khoảng cách quá lớn này bị ép thành xác suất cực đoan như 99,9% — hiện tượng “quá tự tin” (over-confidence).',
},
{
title: 'Bác sĩ trưởng khoa hạ hỏa (tham số T)',

View File

@@ -22,19 +22,19 @@ export const CALIBRATION_TIERS: CalibrationTier[] = [
tier: 1,
labelVi: 'Nhạy cao / Sàng lọc',
labelEn: 'Aggressive / Screening',
triggerVi: 'Bác sĩ nghi ngờ viêm cao từ triệu chứng, chưa thấy trên ảnh.',
ruleVi: 'T = 0.7 (Sharpening)',
triggerVi: 'Bác sĩ nghi ngờ mức độ viêm cao từ triệu chứng, chưa thấy trên ảnh.',
ruleVi: 'T = 0.7',
recommendedT: 0.7,
uiEffectVi:
'Đẩy xác suất biên lên — hạ ngưỡng cảnh báo viêm để không bỏ sót ca ranh giới.',
'Đẩy xác suất biên lên — hạ ngưỡng cảnh báo viêm để không bỏ sót ca hiếm gặp.',
},
{
id: 'standard',
tier: 2,
labelVi: 'Chuẩn / Mặc định',
labelEn: 'Standard Baseline',
triggerVi: 'Vận hành mặc định — chưa có prior lâm sàng từ người dùng.',
ruleVi: 'T = 1.4 (Smoothing)',
triggerVi: 'Vận hành mặc định — chưa có dự đoán từ trước từ người dùng.',
ruleVi: 'T = 1.4',
recommendedT: 1.4,
uiEffectVi:
'Giảm overconfidence của mạng — phân bố thực tế, cân bằng toán học.',
@@ -45,7 +45,7 @@ export const CALIBRATION_TIERS: CalibrationTier[] = [
labelVi: 'Bảo thủ / Hoài nghi',
labelEn: 'Conservative / Skeptical',
triggerVi: 'Bác sĩ tin bệnh nhân khỏe; dùng AI chỉ để kiểm tra lại.',
ruleVi: 'T = 2.2 (Heavy flattening)',
ruleVi: 'T = 2.2',
recommendedT: 2.2,
uiEffectVi:
'Làm phẳng phân bố — chỉ báo dương khi tín hiệu mô hình cực kỳ mạnh.',

View File

@@ -84,12 +84,17 @@ export type ClinicalChatRuntime = 'loading' | 'llm' | 'mock';
export type ModelLoadPhase =
| 'installing-gemma'
| 'resuming-gemma'
// | 'installing-qwen'
| 'loading-gemma';
// | 'loading-qwen';
/** No download progress for this long ⇒ treat the install as stalled (not "loading"). */
export const MODEL_INSTALL_STALL_MS = 15_000;
const MODEL_INSTALL_STALL_POLL_MS = 3_000;
export function isModelInstallPhase(phase: ModelLoadPhase): boolean {
return phase === 'installing-gemma';
return phase === 'installing-gemma' || phase === 'resuming-gemma';
// || phase === 'installing-qwen';
}
@@ -137,6 +142,8 @@ export interface UseClinicalChatResult {
modelLoadProgress: number;
/** Byte transfer label during OPFS install (e.g. "842.3 MB / 1.87 GB"). */
modelInstallTransferLabel: string | null;
/** True when an install/resume has received no bytes for a while — not actually progressing. */
modelLoadStalled: boolean;
modelLoadFading: boolean;
sendMessage: () => void;
stopGeneration: () => void;
@@ -212,6 +219,7 @@ export function useClinicalChat({
const [modelLoadPhase, setModelLoadPhase] = useState<ModelLoadPhase | null>(null);
const [modelLoadProgress, setModelLoadProgress] = useState(0);
const [modelInstallTransferLabel, setModelInstallTransferLabel] = useState<string | null>(null);
const [modelLoadStalled, setModelLoadStalled] = useState(false);
const [modelLoadFading, setModelLoadFading] = useState(false);
const [modeSuggestion, setModeSuggestion] = useState<ModeSuggestion | null>(null);
@@ -365,11 +373,49 @@ export function useClinicalChat({
let cancelled = false;
let fadeTimer: number | undefined;
let stopLoadTicker: (() => void) | undefined;
let stallWatchdogId: number | undefined;
let lastInstallProgressAt = Date.now();
// Highest byte offset seen so far. Retry "heartbeats" re-emit the same offset;
// only a real increase counts as progress.
let lastInstallBytes = -1;
const clearStallWatchdog = () => {
if (stallWatchdogId !== undefined) {
window.clearInterval(stallWatchdogId);
stallWatchdogId = undefined;
}
};
// Flip the overlay from "downloading" to "stalled" when no *new bytes* arrive for a
// while, so a frozen bar (or a resume stuck retrying the same offset) never
// masquerades as active progress.
const startStallWatchdog = () => {
clearStallWatchdog();
lastInstallProgressAt = Date.now();
lastInstallBytes = -1;
setModelLoadStalled(false);
stallWatchdogId = window.setInterval(() => {
if (cancelled) {
return;
}
if (Date.now() - lastInstallProgressAt > MODEL_INSTALL_STALL_MS) {
setModelLoadStalled(true);
setStatusLabel('Tải Gemma bị gián đoạn — đang thử kết nối lại…');
}
}, MODEL_INSTALL_STALL_POLL_MS);
};
const noteInstallProgress = () => {
lastInstallProgressAt = Date.now();
setModelLoadStalled(false);
};
async function finishModelLoad(): Promise<void> {
if (cancelled) {
return;
}
clearStallWatchdog();
setModelLoadStalled(false);
setModelLoadProgress(100);
setModelLoadFading(true);
await new Promise<void>((resolve) => {
@@ -469,9 +515,25 @@ export function useClinicalChat({
if (!cancelled) {
setModelLoadProgress(8);
if (!initialGemma.loadable) {
setModelLoadPhase('installing-gemma');
// A partial checkpoint already on disk means we resume, not start fresh.
const isPartialResume = initialGemma.bytes > 0;
setModelLoadPhase(isPartialResume ? 'resuming-gemma' : 'installing-gemma');
setIsModelLoading(true);
setStatusLabel('Đang cài đặt Gemma 4 E2B về máy…');
setStatusLabel(
isPartialResume
? 'Đang tiếp tục tải Gemma 4 E2B…'
: 'Đang cài đặt Gemma 4 E2B về máy…',
);
if (isPartialResume) {
setModelInstallTransferLabel(
formatInstallTransferLabel({
phase: 'resuming',
bytesLoaded: initialGemma.bytes,
bytesTotal: initialGemma.manifest?.bytes ?? null,
}),
);
}
startStallWatchdog();
}
// else if (!initialQwen.loadable) {
// setModelLoadPhase('installing-qwen');
@@ -486,11 +548,26 @@ export function useClinicalChat({
if (cancelled) {
return;
}
setModelLoadPhase('installing-gemma');
setIsModelLoading(true);
setStatusLabel('Đang cài đặt Gemma 4 E2B về máy…');
setModelInstallTransferLabel(formatInstallTransferLabel(progress));
setModelLoadProgress(mapDownloadProgress(progress));
// Retry heartbeats re-emit the same offset — those must NOT reset the
// watchdog or clear the stalled state, otherwise a stuck resume keeps
// looking like active loading.
const advanced = progress.bytesLoaded > lastInstallBytes;
if (!advanced) {
return;
}
lastInstallBytes = progress.bytesLoaded;
noteInstallProgress();
const resuming = progress.phase === 'resuming';
setModelLoadPhase(resuming ? 'resuming-gemma' : 'installing-gemma');
setStatusLabel(
resuming
? 'Đang tiếp tục tải Gemma 4 E2B…'
: 'Đang cài đặt Gemma 4 E2B về máy…',
);
},
// onQwenDownloadProgress: (progress: QwenDownloadProgress) => {
// if (cancelled) {
@@ -537,17 +614,20 @@ export function useClinicalChat({
);
void preloadGemmaIntoMemory();
} catch (error) {
clearStallWatchdog();
if (!cancelled) {
setIsModelLoading(false);
setModelLoadFading(false);
setModelLoadPhase(null);
setModelInstallTransferLabel(null);
setModelLoadStalled(false);
setRuntime('mock');
const message = error instanceof Error ? error.message : 'không tải được mô hình';
const isNetwork =
error instanceof TypeError ||
(error instanceof DOMException && error.name === 'NetworkError') ||
/network|failed to fetch|interrupted|gián đoạn|network_changed|err_network_changed/i.test(
(error instanceof DOMException &&
(error.name === 'NetworkError' || error.name === 'TimeoutError')) ||
/network|failed to fetch|interrupted|timed out|timeout|gián đoạn|network_changed|err_network_changed/i.test(
message,
);
setStatusLabel(
@@ -563,6 +643,7 @@ export function useClinicalChat({
return () => {
cancelled = true;
stopLoadTicker?.();
clearStallWatchdog();
if (fadeTimer !== undefined) {
window.clearTimeout(fadeTimer);
}
@@ -765,22 +846,26 @@ export function useClinicalChat({
let ollamaThoughtAcc = '';
let ollamaAnswerAcc = '';
const assistantId = createChatMessageId();
const assistantMessage: ClinicalChatMessage = {
id: assistantId,
// A single generation may spawn continuation segments; each segment renders as
// its own bubble with its own reasoning, so continuation thinking never leaks
// into a prior answer. currentAssistantId tracks the bubble being streamed.
let currentAssistantId = createChatMessageId();
let segmentCount = 1;
const spawnAssistantBubble = (id: string, pondering: boolean): ClinicalChatMessage => ({
id,
role: 'assistant',
content: '',
timestamp: new Date(),
streaming: true,
tracksThought: thoughtActive,
pondering: useRemote,
pondering,
ponderingVariant: mode === 'agent' ? 'agent' : 'chat',
};
setMessages((prev) => [...prev, assistantMessage]);
});
setMessages((prev) => [...prev, spawnAssistantBubble(currentAssistantId, useRemote)]);
setStatusLabel(generationStatusLabel(mode, activeLevel));
let plainContentAccumulator = '';
const thoughtParser =
let thoughtParser =
thoughtActive && !useOllamaThoughtStream ? createCotStreamParser() : null;
let thoughtCompleteEmitted = false;
let ponderingCleared = false;
@@ -790,7 +875,7 @@ export function useClinicalChat({
return;
}
ponderingCleared = true;
updateMessage(assistantId, { pondering: false });
updateMessage(currentAssistantId, { pondering: false });
};
const useImperativeStreamPaint = useRemote || thoughtActive;
@@ -801,8 +886,9 @@ export function useClinicalChat({
thoughtComplete?: boolean;
tracksThought?: boolean;
}>((patch) => {
const id = currentAssistantId;
setMessages((prev) =>
prev.map((msg) => (msg.id === assistantId ? { ...msg, ...patch } : msg)),
prev.map((msg) => (msg.id === id ? { ...msg, ...patch } : msg)),
);
});
@@ -814,6 +900,7 @@ export function useClinicalChat({
tracksThought?: boolean;
},
) => {
const id = currentAssistantId;
const hasStreamText =
patch.thoughtContent !== undefined || patch.content !== undefined;
@@ -822,13 +909,13 @@ export function useClinicalChat({
if (patch.thoughtContent !== undefined) {
domHandled =
setClinicalStreamText(
streamTargetKey(assistantId, 'thought'),
streamTargetKey(id, 'thought'),
patch.thoughtContent,
) && domHandled;
}
if (patch.content !== undefined) {
domHandled =
setClinicalStreamText(streamTargetKey(assistantId, 'answer'), patch.content) &&
setClinicalStreamText(streamTargetKey(id, 'answer'), patch.content) &&
domHandled;
}
@@ -840,7 +927,7 @@ export function useClinicalChat({
if (needsReact) {
flushSync(() => {
setMessages((prev) =>
prev.map((msg) => (msg.id === assistantId ? { ...msg, ...patch } : msg)),
prev.map((msg) => (msg.id === id ? { ...msg, ...patch } : msg)),
);
});
}
@@ -861,6 +948,45 @@ export function useClinicalChat({
}
};
// Freeze the bubble being streamed using its OWN parser/accumulator, so its
// reasoning + answer slice stay self-contained.
const finalizeCurrentBubble = () => {
if (thoughtParser) {
const snapshot = thoughtParser.finalize();
updateMessage(currentAssistantId, {
content: snapshot.content,
thoughtContent: snapshot.thoughtContent || undefined,
tracksThought: true,
thoughtComplete: true,
streaming: false,
pondering: false,
});
} else {
updateMessage(currentAssistantId, {
content: plainContentAccumulator,
streaming: false,
pondering: false,
});
}
clearClinicalStreamTargetsForMessage(currentAssistantId);
};
// Continuation → close the current bubble and open a fresh one for the next segment.
const startNextSegmentBubble = () => {
finalizeCurrentBubble();
if (!useImperativeStreamPaint) {
edgeStreamRaf.cancel();
}
const nextId = createChatMessageId();
currentAssistantId = nextId;
segmentCount += 1;
thoughtParser =
thoughtActive && !useOllamaThoughtStream ? createCotStreamParser() : null;
plainContentAccumulator = '';
thoughtCompleteEmitted = false;
setMessages((prev) => [...prev, spawnAssistantBubble(nextId, false)]);
};
try {
const runTurn = () =>
runClinicalChatTurn(
@@ -884,6 +1010,13 @@ export function useClinicalChat({
},
(event: ClinicalChatStreamEvent) => {
try {
if (event.type === 'segment_boundary') {
// segment 1 is the bubble we already opened; only continuations spawn a new one.
if (event.segment >= 2 && !useOllamaThoughtStream) {
startNextSegmentBubble();
}
return;
}
if (event.type === 'thought_token') {
if (!event.partial || !useOllamaThoughtStream) {
return;
@@ -955,15 +1088,15 @@ export function useClinicalChat({
if (abortController.signal.aborted) {
disposeStreamThrottle();
updateMessage(assistantId, { streaming: false });
updateMessage(currentAssistantId, { streaming: false });
return;
}
disposeStreamThrottle();
clearClinicalStreamTargetsForMessage(assistantId);
clearClinicalStreamTargetsForMessage(currentAssistantId);
if (useOllamaThoughtStream) {
updateMessage(assistantId, {
updateMessage(currentAssistantId, {
content: ollamaAnswerAcc || result.finalAnswer,
thoughtContent: ollamaThoughtAcc || undefined,
tracksThought: true,
@@ -973,8 +1106,11 @@ export function useClinicalChat({
});
} else if (thoughtActive && thoughtParser) {
const snapshot = thoughtParser.finalize();
const finalContent = snapshot.content || result.finalAnswer;
updateMessage(assistantId, {
// Only the sole segment may borrow the merged finalAnswer; continuation
// bubbles must keep just their own parsed slice to avoid duplication.
const finalContent =
snapshot.content || (segmentCount === 1 ? result.finalAnswer : '');
updateMessage(currentAssistantId, {
content: finalContent,
thoughtContent: snapshot.thoughtContent || undefined,
tracksThought: true,
@@ -983,8 +1119,9 @@ export function useClinicalChat({
pondering: false,
});
} else {
updateMessage(assistantId, {
content: result.finalAnswer,
updateMessage(currentAssistantId, {
content:
plainContentAccumulator || (segmentCount === 1 ? result.finalAnswer : ''),
streaming: false,
pondering: false,
});
@@ -999,12 +1136,12 @@ export function useClinicalChat({
);
} catch (error) {
disposeStreamThrottle();
clearClinicalStreamTargetsForMessage(assistantId);
clearClinicalStreamTargetsForMessage(currentAssistantId);
if (error instanceof DOMException && error.name === 'AbortError') {
updateMessage(assistantId, { streaming: false, pondering: false });
updateMessage(currentAssistantId, { streaming: false, pondering: false });
return;
}
updateMessage(assistantId, {
updateMessage(currentAssistantId, {
content:
error instanceof Error
? `Không thể trả lời: ${error.message}`
@@ -1132,6 +1269,7 @@ export function useClinicalChat({
modelLoadPhase,
modelLoadProgress,
modelInstallTransferLabel,
modelLoadStalled,
modelLoadFading,
sendMessage,
stopGeneration,

View File

@@ -12,7 +12,10 @@ import {
normalizeBackendSeverity,
type SynovitisSeverityResult,
} from '../data/synovitisSeverity';
import { getCachedCvAnalyzeResult, getCvAnalyzeResultsForProfile } from '../lib/cvAnalyzeApi';
import { getCachedCvAnalyzeResult, getCvAnalyzeResultsForProfile, getCvAnalyzeResultsForProfileCelery, clearCvAnalyzeResultCache, type CvFrameAnalyzeResult } from '../lib/cvAnalyzeApi';
import { clearSegmentationResultCache } from '../lib/segmentationApi';
import { clearAngleClassificationResultCache } from '../lib/angleClassificationApi';
import { clearMlInferenceCacheForPatient } from '../lib/mlInferenceCacheStore';
import type { ProfileMlContext } from '../lib/mlInferenceCacheKeys';
import type { ScanFrame } from '../data/scanFrames';
import { interpretSegmentationForDisplay } from '../lib/interpretSegmentationResult';
@@ -104,6 +107,7 @@ export function useSegmentationOverlay(
imageSrc: string,
profileFrames?: ScanFrame[],
patientMrn?: string,
useCelery?: boolean,
): UseSegmentationOverlayResult {
const [overlaySrc, setOverlaySrc] = useState<string | null>(null);
const [interpretation, setInterpretation] = useState<SegmentationDisplayInterpretation | null>(null);
@@ -117,7 +121,15 @@ export function useSegmentationOverlay(
const [source, setSource] = useState<'backend' | null>(null);
const [retryNonce, setRetryNonce] = useState(0);
const retry = () => setRetryNonce((n) => n + 1);
const retry = () => {
clearCvAnalyzeResultCache();
clearSegmentationResultCache();
clearAngleClassificationResultCache();
if (patientMrn) {
clearMlInferenceCacheForPatient(patientMrn);
}
setRetryNonce((n) => n + 1);
};
const latestSegmentationRef = useRef<SegmentationApiResult | undefined>(undefined);
@@ -161,7 +173,14 @@ export function useSegmentationOverlay(
const mlContext: ProfileMlContext | undefined = patientMrn
? { patientMrn }
: undefined;
const results = await getCvAnalyzeResultsForProfile(frameRefs, mlContext);
let results: Map<string, CvFrameAnalyzeResult>;
if (useCelery) {
results = await getCvAnalyzeResultsForProfileCelery(frameRefs, mlContext);
} else {
results = await getCvAnalyzeResultsForProfile(frameRefs, mlContext);
}
if (cancelled) return;
const cvResult = results.get(frameId);
@@ -235,7 +254,7 @@ export function useSegmentationOverlay(
return () => {
cancelled = true;
};
}, [frameId, imageSrc, profileFrames, patientMrn, retryNonce]);
}, [frameId, imageSrc, profileFrames, patientMrn, retryNonce, useCelery]);
return {
overlaySrc,

View File

@@ -31,6 +31,26 @@ export interface BackendCvAnalyzeBatchResponse {
detail?: string;
}
export interface BackendCvCelerySubmitResponse {
success: boolean;
job_id: string;
image_count: number;
mode: string;
chunk_size: number;
detail?: string;
}
export interface BackendCvCeleryStatusResponse {
status: 'pending' | 'completed' | 'failed' | 'unknown';
completed?: number;
total?: number;
progress?: number;
image_count?: number;
results?: BackendSegmentationResponse[];
errors?: string[];
detail?: string;
}
export interface CvFrameAnalyzeResult {
raw: BackendSegmentationResponse;
segmentation: SegmentationApiResult;
@@ -222,3 +242,166 @@ export async function getCvAnalyzeResultsForProfile(
}
return resolved;
}
/**
* Submit CV batch for async Celery chunk fan-out.
* Returns job_id immediately — poll with pollCvAnalyzeBatchCelery().
*/
export async function submitCvAnalyzeBatchCelery(
frames: ProfileFrameRef[],
apiBase = getSegmentApiBase(),
): Promise<string> {
if (frames.length === 0) {
throw new Error('frames must not be empty');
}
const formData = new FormData();
const files = await Promise.all(
frames.map((frame, index) =>
imageUrlToFile(frame.src, `${frame.id || `frame-${index}`}.png`),
),
);
files.forEach((file) => formData.append('images', file));
formData.append('frame_ids', JSON.stringify(frames.map((frame) => frame.id)));
const response = await fetch(`${apiBase}/api/test/analyze/batch/celery`, {
method: 'POST',
body: formData,
});
const payload = await readMlApiJson<BackendCvCelerySubmitResponse>(response);
if (!response.ok) {
throw new Error(`Celery batch submit error (${response.status})`);
}
if (!payload.success || !payload.job_id) {
throw new Error('Celery batch submit returned success=false or missing job_id');
}
return payload.job_id;
}
/**
* Poll Celery batch job status.
* Returns status object — call again if status === 'pending'.
*/
export async function pollCvAnalyzeBatchCelery(
jobId: string,
apiBase = getSegmentApiBase(),
): Promise<BackendCvCeleryStatusResponse> {
const response = await fetch(`${apiBase}/api/test/analyze/batch/celery/${encodeURIComponent(jobId)}`);
const payload = await readMlApiJson<BackendCvCeleryStatusResponse>(response);
if (!response.ok) {
throw new Error(payload.detail ?? `Celery batch poll error (${response.status})`);
}
return payload;
}
/**
* Async CV pipeline via Celery chunk fan-out.
* Submits job, polls until completed/failed, maps results into cache.
* Returns cached results immediately if all frames are already available.
*/
export async function getCvAnalyzeResultsForProfileCelery(
frames: ProfileFrameRef[],
mlContext?: ProfileMlContext,
apiBase = getSegmentApiBase(),
signal?: AbortSignal,
): Promise<Map<string, CvFrameAnalyzeResult>> {
if (frames.length === 0) {
return new Map();
}
const cacheKey = buildBatchCacheKey(frames.map((f) => f.id));
// Return fully cached results without hitting the backend.
const cached = new Map<string, CvFrameAnalyzeResult>();
for (const frame of frames) {
const hit = cvAnalyzeResultCache.get(frame.id);
if (hit) {
cached.set(frame.id, hit);
}
}
if (cached.size === frames.length) {
return cached;
}
// Coalesce in-flight requests for the same batch of frames.
const existing = inflightBatchByKey.get(cacheKey);
if (existing) {
return existing;
}
const batchPromise = (async (): Promise<Map<string, CvFrameAnalyzeResult>> => {
const jobId = await submitCvAnalyzeBatchCelery(frames, apiBase);
const pollIntervalMs = 2000;
const submitTime = Date.now();
while (true) {
if (signal?.aborted) {
throw new Error('Celery batch poll aborted');
}
const status = await pollCvAnalyzeBatchCelery(jobId, apiBase);
if (status.status === 'completed') {
const byFrameId = new Map<string, CvFrameAnalyzeResult>();
if (status.results) {
for (const item of status.results) {
const frameId = item.frame_id;
if (!frameId) continue;
const cvResult = mapPayloadToCvResult(item);
byFrameId.set(frameId, cvResult);
cvAnalyzeResultCache.set(frameId, cvResult);
segmentationResultCache.set(frameId, cvResult.segmentation);
if (cvResult.angle) {
angleResultCache.set(frameId, cvResult.angle);
}
}
}
if (mlContext?.patientMrn && status.results) {
await persistCvBatch(
frames,
new Map([...byFrameId.entries()]),
mlContext,
);
}
const totalMs = Date.now() - submitTime;
console.log(
`[cvAnalyzeApi] Celery batch completed jobId=${jobId} frames=${frames.length} totalMs=${totalMs}`,
);
return byFrameId;
}
if (status.status === 'failed') {
const totalMs = Date.now() - submitTime;
console.error(
`[cvAnalyzeApi] Celery batch failed jobId=${jobId} frames=${frames.length} totalMs=${totalMs}`,
);
throw new Error(status.errors?.join('; ') ?? 'Celery batch job failed');
}
if (status.status === 'unknown') {
const totalMs = Date.now() - submitTime;
console.error(
`[cvAnalyzeApi] Celery batch unknown jobId=${jobId} frames=${frames.length} totalMs=${totalMs}`,
);
throw new Error(status.detail ?? `Celery job ${jobId} not found`);
}
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
}
})();
inflightBatchByKey.set(cacheKey, batchPromise);
try {
return await batchPromise;
} finally {
inflightBatchByKey.delete(cacheKey);
}
}

View File

@@ -24,12 +24,12 @@ function envFlag(name: string, defaultValue: boolean): boolean {
/** Try local Gemma worker when OPFS model is available. Falls back to mock replies otherwise. */
export function useLocalLlmWhenAvailable(): boolean {
return envFlag('VITE_CLINICAL_CHAT_USE_LLM', true);
return import.meta.env.VITE_CLINICAL_CHAT_USE_LLM !== 'false';
}
/** Use fixture tool results instead of BFF (default on in dev). */
export function useMockAgentTools(): boolean {
return envFlag('VITE_CLINICAL_CHAT_MOCK_TOOLS', true);
return import.meta.env.VITE_CLINICAL_CHAT_MOCK_TOOLS !== 'false';
}
export function clinicalChatBffBaseUrl(): string {

View File

@@ -122,6 +122,7 @@ export class LlmWorkerClient {
promptOptions: PromptOptions,
decode: DecodeParams,
onToken?: (partial: string) => void,
onSegmentStart?: (segment: number) => void,
): Promise<{ rawOutput: string; stats: GenerationStats }> {
const id = requestId();
this.activeGenerateRequestId = id;
@@ -136,6 +137,7 @@ export class LlmWorkerClient {
},
reject,
onToken: (partial) => onToken?.(partial),
onSegmentStart: (segment) => onSegmentStart?.(segment),
});
this.worker.postMessage({
type: 'generate',

View File

@@ -16,19 +16,23 @@ export function formatInstallTransferLabel(progress: DownloadProgress): string {
export function mapDownloadProgress(progress: DownloadProgress): number {
switch (progress.phase) {
case 'downloading':
case 'resuming':
case 'resuming': {
if (!progress.bytesTotal || progress.bytesTotal <= 0) {
return progress.phase === 'resuming' ? 14 : 12;
return progress.phase === 'resuming' ? 6 : 4;
}
return 10 + Math.round((progress.bytesLoaded / progress.bytesTotal) * 58);
// Track the real byte fraction so the bar matches the "X GB / Y GB" label,
// reserving the last few percent for verify/write before completion.
const fraction = Math.min(1, progress.bytesLoaded / progress.bytesTotal);
return 4 + Math.round(fraction * 92);
}
case 'hashing':
return 72;
return 97;
case 'writing':
return 76;
return 98;
case 'done':
return 78;
return 99;
default:
return 10;
return 4;
}
}

View File

@@ -66,12 +66,26 @@ interface DownloadProbe {
supportsRange: boolean;
}
/** HEAD/range probes should return fast; time out so a hung socket surfaces as a retriable error. */
const PROBE_TIMEOUT_MS = 12_000;
function probeTimeoutSignal(): AbortSignal | undefined {
return typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function'
? AbortSignal.timeout(PROBE_TIMEOUT_MS)
: undefined;
}
async function probeModelDownloadUrl(url: string): Promise<DownloadProbe> {
let response = await fetch(url, { method: 'HEAD', redirect: 'follow' });
let response = await fetch(url, {
method: 'HEAD',
redirect: 'follow',
signal: probeTimeoutSignal(),
});
if (!response.ok) {
response = await fetch(url, {
headers: { Range: 'bytes=0-0' },
redirect: 'follow',
signal: probeTimeoutSignal(),
});
}
@@ -164,7 +178,7 @@ function isRetriableDownloadError(error: unknown): boolean {
if (error instanceof TypeError) {
return true;
}
if (error instanceof DOMException && error.name === 'NetworkError') {
if (error instanceof DOMException && (error.name === 'NetworkError' || error.name === 'TimeoutError')) {
return true;
}
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
@@ -175,6 +189,8 @@ function isRetriableDownloadError(error: unknown): boolean {
message.includes('failed to fetch') ||
message.includes('load failed') ||
message.includes('interrupted') ||
message.includes('timed out') ||
message.includes('timeout') ||
message.includes('gián đoạn') ||
message.includes('http 502') ||
message.includes('http 503') ||
@@ -328,6 +344,17 @@ export async function checkOpfsModelLoadable(): Promise<OpfsModelLoadableStatus>
const file = await readOpfsModelFile(MODEL_TASK_FILENAME);
if (file && file.size > 0 && (await isReadable(file))) {
// An interrupted download never wrote a manifest (it is written last). Such a
// file can still clear the >500 MB header check while being a truncated,
// unloadable checkpoint — treat it as resumable, not loadable, so we resume the
// download instead of trying to init MediaPipe on a fragmented model.
if (file.size < EXPECTED_MODEL_TASK_BYTES) {
return invalidStatus(
`Partial download in OPFS (${formatBytes(file.size)} of ${formatBytes(EXPECTED_MODEL_TASK_BYTES)}). Will resume on next install.`,
manifest,
file,
);
}
const validationError = await validateTaskCandidate(file);
if (validationError) {
return invalidStatus(validationError, manifest, file);

View File

@@ -30,7 +30,9 @@ export interface DirectChatTurnResult {
export type ClinicalChatStreamEvent =
| AgentEvent
| { type: 'thought_token'; partial: string };
| { type: 'thought_token'; partial: string }
/** A new local-model generation segment started (continuation) → spawn a new bubble. */
| { type: 'segment_boundary'; segment: number };
export interface RunClinicalChatTurnInput {
inferenceMode: InferenceMode;
@@ -56,10 +58,17 @@ function buildChatHistory(
if (message.streaming || !message.content.trim()) {
continue;
}
if (message.role === 'user') {
turns.push({ role: 'user', text: message.content });
} else if (message.role === 'assistant') {
turns.push({ role: 'assistant', text: message.content });
if (message.role !== 'user' && message.role !== 'assistant') {
continue;
}
const role: GemmaHistoryTurn['role'] = message.role === 'user' ? 'user' : 'assistant';
// Continuation segments render as separate assistant bubbles; coalesce consecutive
// same-role turns so Gemma sees one well-formed alternating turn.
const previous = turns[turns.length - 1];
if (previous && previous.role === role) {
previous.text = `${previous.text}${message.content}`;
} else {
turns.push({ role, text: message.content });
}
}
return turns.slice(-maxHistoryTurns * 2);
@@ -126,6 +135,7 @@ export async function runDirectChatTurn(
historyMessages: ClinicalChatMessage[];
onToken?: (partial: string) => void;
onThoughtToken?: (partial: string) => void;
onSegmentStart?: (segment: number) => void;
},
signal?: AbortSignal,
): Promise<DirectChatTurnResult> {
@@ -206,6 +216,7 @@ export async function runDirectChatTurn(
promptOptions,
decode,
input.onToken,
input.onSegmentStart,
);
if (signal?.aborted) {
@@ -252,6 +263,7 @@ export async function runClinicalChatTurn(
historyMessages: input.historyMessages,
onToken: (partial) => onEvent?.({ type: 'final_token', partial }),
onThoughtToken: (partial) => onEvent?.({ type: 'thought_token', partial }),
onSegmentStart: (segment) => onEvent?.({ type: 'segment_boundary', segment }),
},
signal,
);

View File

@@ -206,7 +206,7 @@ function ClinicalWorkspaceContent({
<WorkspaceShell
zoneA={
<div ref={canvasStackRef} className="workspace-canvas-stack">
<DiagnosticCanvas
<DiagnosticCanvas
showMask={showMask}
onToggleMask={() => {
setShowMask((previous) => {
@@ -227,6 +227,7 @@ function ClinicalWorkspaceContent({
onSegmentationLoadingChange={setIsSegmentationLoading}
patientMrn={patient.mrn}
patientId={patient.id}
useCelery={import.meta.env.VITE_USE_CV_CELERY !== 'false'}
onRegisterSnapshotCapture={(capture) => {
captureSnapshotRef.current = capture;
}}

View File

@@ -32,7 +32,9 @@ interface ImportMetaEnv {
readonly VITE_OLLAMA_CHAT_URL?: string;
readonly VITE_OLLAMA_MODEL?: string;
readonly VITE_USE_BACKEND_SEGMENTATION?: string;
readonly VITE_USE_CV_CELERY?: string;
readonly VITE_SEGMENT_API_BASE?: string;
readonly VITE_MODAL_OLLAMA_TARGET?: string;
}
interface ImportMeta {

View File

@@ -338,16 +338,16 @@ async function generateResponse(
}
const baseBeforeSegment = combinedOutput;
let emittedLength = combinedOutput.length;
const tokenBatcher = createTokenBatcher(requestId, segmentNumber);
let emittedSegmentLength = 0;
// Stream each segment's RAW tokens (its own thought channel + answer)
// independently. The client renders one bubble per segment and parses its own
// channel, so continuation thinking never leaks into a prior answer. The
// cross-segment merge below is only for the model's continuation prompt + stats.
const segmentRaw = await streamSegment(prompt, requestId, (_partial, segmentSoFar) => {
const merged =
segments === 0
? segmentSoFar
: mergeContinuationOutput(baseBeforeSegment, segmentSoFar, chainOfThought);
const delta = merged.slice(emittedLength);
emittedLength = merged.length;
const delta = segmentSoFar.slice(emittedSegmentLength);
emittedSegmentLength = segmentSoFar.length;
if (delta.length > 0) {
tokenBatcher.push(delta);
}

View File

@@ -15,7 +15,8 @@
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
"noUncheckedSideEffectImports": true,
"types": ["node"]
},
"include": ["src"]
"include": ["src", "vite.config.ts"]
}

File diff suppressed because one or more lines are too long

View File

@@ -1,8 +1,27 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'node:path';
import fs from 'fs';
import { load } from 'js-yaml';
function loadFrontendConfig(): Record<string, string> {
try {
const raw = fs.readFileSync('config/frontend.config.yaml', 'utf8');
return load(raw) as Record<string, string>;
} catch {
return {};
}
}
const frontendConfig = loadFrontendConfig();
const defineVars: Record<string, string> = {};
for (const [key, value] of Object.entries(frontendConfig)) {
defineVars[`import.meta.env.${key}`] = JSON.stringify(String(value));
}
const MODAL_OLLAMA_TARGET =
frontendConfig.VITE_MODAL_OLLAMA_TARGET ??
'https://dtj-tran--ollama-gemma4-e4b-ollamaserver-web.modal.run';
export default defineConfig({
@@ -52,4 +71,5 @@ export default defineConfig({
},
},
},
define: defineVars,
});