test_workflow
Some checks failed
Backend ECR Deployment / build-and-push (push) Has been cancelled
Backend ECR Deployment / notify-deploy (push) Has been cancelled
Backend Modal Build / notify-deploy (push) Has been cancelled
Backend Modal Build / Build & Push via Modal (push) Has been cancelled

This commit is contained in:
DatTT127
2026-07-18 17:48:19 +07:00
parent 7d5c583475
commit 57a8bac1be
87 changed files with 36291 additions and 155 deletions

View File

@@ -0,0 +1,4 @@
cd workspace/sprint_1_2/CODEBASE/frontend/implementation
npm install
npm run build
npm run preview

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480">
<defs>
<radialGradient id="heat" cx="55%" cy="42%" r="35%">
<stop offset="0%" stop-color="#ff4444" stop-opacity="0.8"/>
<stop offset="50%" stop-color="#ffaa00" stop-opacity="0.4"/>
<stop offset="100%" stop-color="#000000" stop-opacity="0"/>
</radialGradient>
<radialGradient id="heat2" cx="38%" cy="55%" r="25%">
<stop offset="0%" stop-color="#ff6600" stop-opacity="0.5"/>
<stop offset="100%" stop-color="#000000" stop-opacity="0"/>
</radialGradient>
</defs>
<ellipse cx="350" cy="210" rx="120" ry="80" fill="url(#heat)"/>
<ellipse cx="250" cy="270" rx="70" ry="50" fill="url(#heat2)"/>
</svg>

After

Width:  |  Height:  |  Size: 711 B

View File

@@ -0,0 +1,14 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Figma MSK_Workspace_UIX-Design node 244:2415 -->
<path
d="M5.25 12.75C3.75 11.25 3.25 9.25 3.75 7.25C4.5 5 6.75 3.5 9.5 3.75C12.25 4 14.25 5.75 14.75 8.25C15.25 10.75 14.25 13 12.5 14.25M11.75 13.5C12.15 13 12.4 12.4 12.5 11.75"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle cx="6.75" cy="7.75" r="1.05" fill="currentColor" />
<circle cx="9.25" cy="6.5" r="1.05" fill="currentColor" />
<circle cx="11.75" cy="6.5" r="1.05" fill="currentColor" />
<circle cx="14.25" cy="7.75" r="1.05" fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 702 B

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
:root{--color-primary: #056b58;--color-on-primary: #ffffff;--color-primary-container: #76c8b1;--color-on-primary-container: #005344;--color-secondary: #23686c;--color-on-secondary: #ffffff;--color-secondary-container: #adeef2;--color-tertiary: #5f5983;--color-tertiary-container: #bab3e2;--color-error: #ba1a1a;--color-error-container: #ffdad6;--color-surface: #f5faf9;--color-surface-container: #eaefee;--color-surface-container-high: #e4e9e8;--color-on-surface: #171d1c;--color-on-surface-variant: #3e4945;--color-outline: #6f7975;--color-outline-variant: #bec9c4;--glass-bg: rgba(255, 255, 255, .55);--glass-border: rgba(5, 107, 88, .18);--glass-shadow: 0 10px 30px rgba(77, 141, 145, .08);--blur-glass: blur(12px);--radius-sm: .25rem;--radius-md: .75rem;--radius-lg: 1rem;--radius-full: 9999px;--font-headline: "Manrope", system-ui, sans-serif;--font-body: "Work Sans", system-ui, sans-serif;--space-sm: 8px;--space-md: 16px;--space-lg: 24px;--topbar-h: 56px;--bottombar-h: 40px}*,*:before,*:after{box-sizing:border-box}html,body,#root{height:100%;margin:0}body{font-family:var(--font-body);font-size:16px;line-height:1.5;color:var(--color-on-surface);background:radial-gradient(ellipse at 20% 0%,rgba(118,200,177,.25),transparent 50%),radial-gradient(ellipse at 80% 100%,rgba(173,238,242,.2),transparent 45%),var(--color-surface);-webkit-font-smoothing:antialiased}h1,h2,h3,h4{font-family:var(--font-headline);margin:0}button,input,textarea,select{font:inherit}a{color:inherit;text-decoration:none}.glass{background:var(--glass-bg);backdrop-filter:var(--blur-glass);-webkit-backdrop-filter:var(--blur-glass);border:1px solid var(--glass-border);box-shadow:var(--glass-shadow)}.glass-elevated{background:#ffffffb8;backdrop-filter:var(--blur-glass);-webkit-backdrop-filter:var(--blur-glass);border:1px solid rgba(5,107,88,.22);box-shadow:0 16px 40px #4d8d911f}.tnum{font-variant-numeric:tabular-nums}.app-shell{display:flex;flex-direction:column;min-height:100%}.app-main{flex:1;display:flex;flex-direction:column;min-height:0}.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:10px 18px;border-radius:var(--radius-md);border:none;cursor:pointer;font-weight:600;font-size:14px;transition:transform .15s,box-shadow .15s,opacity .15s}.btn:active{transform:scale(.98)}.btn-primary{background:var(--color-secondary);color:var(--color-on-secondary);box-shadow:inset 0 1px #fff3}.btn-primary:hover{background:#1a5559}.btn-secondary{background:var(--glass-bg);-webkit-backdrop-filter:var(--blur-glass);backdrop-filter:var(--blur-glass);color:var(--color-primary);border:1px solid var(--color-primary)}.btn-ghost{background:transparent;color:var(--color-on-surface-variant);border:1px solid var(--color-outline-variant)}.chip{display:inline-flex;align-items:center;padding:4px 10px;border-radius:var(--radius-full);font-size:12px;font-weight:600;letter-spacing:.02em}.chip-urgent{background:var(--color-error-container);color:var(--color-error)}.chip-processing{background:var(--color-secondary-container);color:var(--color-secondary)}.chip-followup{background:var(--color-tertiary-container);color:var(--color-tertiary)}.chip-success{background:var(--color-primary-container);color:var(--color-on-primary-container)}.icon-btn{width:40px;height:40px;border-radius:var(--radius-full);border:1px solid var(--glass-border);background:var(--glass-bg);-webkit-backdrop-filter:var(--blur-glass);backdrop-filter:var(--blur-glass);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;color:var(--color-secondary)}.icon-btn:hover{border-color:var(--color-primary)}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

View File

@@ -0,0 +1,79 @@
import{j as a}from"./index-BCa3bqPy.js";function m({title:e="Lumina MSK",subtitle:t,showBack:n,onBack:r,actions:o,phase:s}){return a.jsxs("header",{className:"topbar glass",children:[a.jsxs("div",{className:"topbar__left",children:[n&&a.jsx("button",{type:"button",className:"icon-btn",onClick:r,"aria-label":"Quay lại",children:"←"}),a.jsxs("div",{className:"topbar__brand",children:[a.jsx("span",{className:"topbar__logo",children:"◈"}),a.jsxs("div",{children:[a.jsx("h1",{className:"topbar__title",children:e}),t&&a.jsx("p",{className:"topbar__subtitle",children:t})]})]})]}),a.jsx("div",{className:"topbar__center",children:s&&a.jsx("span",{className:"topbar__phase chip chip-success",children:s})}),a.jsx("div",{className:"topbar__actions",children:o}),a.jsx("style",{children:`
.topbar {
height: var(--topbar-h);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--space-md);
gap: var(--space-md);
border-radius: 0;
border-left: none;
border-right: none;
border-top: none;
position: sticky;
top: 0;
z-index: 100;
}
.topbar__left {
display: flex;
align-items: center;
gap: var(--space-sm);
min-width: 0;
}
.topbar__brand {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.topbar__logo {
color: var(--color-primary);
font-size: 20px;
}
.topbar__title {
font-size: 16px;
font-weight: 700;
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.topbar__subtitle {
margin: 0;
font-size: 12px;
color: var(--color-on-surface-variant);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.topbar__center {
flex-shrink: 0;
}
.topbar__phase {
font-size: 11px;
}
.topbar__actions {
display: flex;
align-items: center;
gap: var(--space-sm);
flex-shrink: 0;
}
`})]})}function b(){return a.jsxs("footer",{className:"bottombar glass",children:[a.jsx("span",{className:"bottombar__copyright",children:"© 2026, by VKIST"}),a.jsx("style",{children:`
.bottombar {
height: var(--bottombar-h);
display: flex;
align-items: center;
justify-content: center;
padding: 0 var(--space-md);
font-size: 12px;
color: var(--color-on-surface-variant);
border-radius: 0;
border-left: none;
border-right: none;
border-bottom: none;
flex-shrink: 0;
}
.bottombar__copyright {
letter-spacing: 0.02em;
}
`})]})}function N({children:e,topBar:t}){return a.jsxs("div",{className:"app-shell",children:[a.jsx(m,{...t}),a.jsx("main",{className:"app-main",children:e}),a.jsx(b,{})]})}const d="poc-v2-spec-cv-seg-norm",h=1440*60*1e3;function x(e,t,n,r=d){return`${e}|${t}|${n}|${r}`}function S(e,t,n=d){const r=[...t].sort().join(",");return`${e}|${n}|analyze:${r}`}async function E(e){const t=await fetch(e);if(!t.ok)throw new Error(`Failed to load image for hash (${t.status})`);const n=await t.arrayBuffer(),r=await crypto.subtle.digest("SHA-256",n);return Array.from(new Uint8Array(r)).map(o=>o.toString(16).padStart(2,"0")).join("")}const y="lumina-msk-ml",_=1,i="frame-inference-cache";function p(){return new Promise((e,t)=>{const n=indexedDB.open(y,_);n.onerror=()=>t(n.error??new Error("Failed to open ML cache IndexedDB")),n.onsuccess=()=>e(n.result),n.onupgradeneeded=()=>{const r=n.result;if(!r.objectStoreNames.contains(i)){const o=r.createObjectStore(i,{keyPath:"cacheKey"});o.createIndex("patientMrn","patientMrn",{unique:!1}),o.createIndex("frameId","frameId",{unique:!1})}}})}function l(e){return new Promise((t,n)=>{e.onsuccess=()=>t(e.result),e.onerror=()=>n(e.error??new Error("IndexedDB request failed"))})}function f(e){return e.pipelineVersion!==d?!1:Date.now()-e.cachedAt<h}async function g(e){try{const t=await p(),n=await l(t.transaction(i,"readonly").objectStore(i).get(e));return t.close(),!n||!f(n)?null:n}catch{return null}}async function I(e,t,n,r){try{const o=x(e,t,n),s=await g(o),c={cacheKey:o,patientMrn:e,frameId:t,contentHash:n,pipelineVersion:d,cachedAt:Date.now(),segmentation:r.segmentation!==void 0?r.segmentation:(s==null?void 0:s.segmentation)??null,angle:r.angle!==void 0?r.angle:(s==null?void 0:s.angle)??null},u=await p();await l(u.transaction(i,"readwrite").objectStore(i).put(c)),u.close()}catch{}}function v(e){const t=e==null?void 0:e.severity;if(!t||typeof t!="object")return null;const n=t.level;return typeof n!="number"||!Number.isFinite(n)?null:Math.min(3,Math.max(0,Math.round(n)))}async function w(e){var t;try{const n=await p(),r=await l(n.transaction(i,"readonly").objectStore(i).index("patientMrn").getAll(e));n.close();let o=null;for(const s of r){if(!f(s))continue;const c=v((t=s.segmentation)==null?void 0:t.raw);c!=null&&(o===null||c>o)&&(o=c)}return o}catch{return null}}async function M(e){const t=[...new Set(e)],n=await Promise.all(t.map(async r=>[r,await w(r)]));return Object.fromEntries(n)}async function A(e){try{const t=await p(),r=t.transaction(i,"readwrite").objectStore(i),o=r.index("patientMrn"),s=await l(o.getAllKeys(e));for(const c of s)await l(r.delete(c));t.close()}catch{}}export{N as A,x as a,S as b,g as c,A as d,E as h,M as l,I as m};

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

View File

@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480">
<path
d="M200 220 Q300 160 420 210 T500 300 Q380 380 260 350 T190 270 Z"
fill="none"
stroke="#056b58"
stroke-width="3"
stroke-dasharray="8 4"
opacity="0.85"
/>
<ellipse
cx="340"
cy="230"
rx="55"
ry="35"
fill="#76c8b1"
opacity="0.35"
stroke="#056b58"
stroke-width="2"
/>
<text x="310" y="235" fill="#ffffff" font-family="sans-serif" font-size="11" font-weight="600">M<EFBFBD>ng H</text>
</svg>

View File

@@ -0,0 +1,35 @@
# Stitch Project Assets
**Project:** MSK Collaborative Workspace
**Project ID:** `17628337625105266704`
| Screen | Screen ID | Route mapping |
|--------|-----------|---------------|
| Danh sách công việc - Lumina MSK | `9d638112797c4b8d8a100d526bab43f2` | `/` (Patient Worklist) |
| Chế độ Kiểm tra Lâm sàng - Lumina MSK (VN) | `fa207d6a4f684f32a3b99706275daad9` | `/workspace/:id` (Clinical Review) |
| Không gian chẩn đoán - AI Review (VN) | `03c138643dd9427aaa59a6e16d089793` | Safety overlay state |
| Chế độ siêu âm gốc - Metadata Tinh gọn (Refined Raw View) | `85ece6c4ed6f4c179042b59813237d44` | Segmentation legend bubble on `DiagnosticCanvas` |
| Annotation Workspace with AI Hub | `3bdfcf6ba964447d88b98c8b9b0df9f1` | Floating `AnnotationRibbon` on `DiagnosticCanvas` |
## Fetching assets
Stitch API requires OAuth2 (`STITCH_ACCESS_TOKEN` + `GOOGLE_CLOUD_PROJECT`), not a generic API key.
```bash
# Using @google/stitch-sdk (after OAuth setup)
npm install @google/stitch-sdk
STITCH_ACCESS_TOKEN=... GOOGLE_CLOUD_PROJECT=... node scripts/fetch-stitch.mjs
```
Or use Stitch MCP `get_screen` with `projectId` and `screenId`, then download:
- `htmlCode.downloadUrl` → save to `public/assets/stitch/<screen>/index.html`
- `screenshot.downloadUrl` → save to `public/assets/stitch/<screen>/screenshot.png`
## Local placeholders
This mockup uses handcrafted SVG assets in `public/assets/` aligned with `spec/DESIGN.md` tokens, since Stitch OAuth was unavailable during build.
Reference thumbnails (design intent):
- `worklist-reference.svg`
- `clinical-review-reference.svg`
- `ai-review-reference.svg`

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300">
<rect width="400" height="300" fill="#f5faf9" rx="12"/>
<rect x="0" y="0" width="400" height="40" fill="rgba(255,255,255,0.55)"/>
<rect x="16" y="52" width="240" height="232" fill="#0a0f0e" rx="12" opacity="0.45"/>
<rect x="60" y="80" width="280" height="180" fill="rgba(255,255,255,0.85)" stroke="#ba1a1a" stroke-width="2" rx="12"/>
<text x="80" y="110" fill="#ba1a1a" font-family="sans-serif" font-size="12" font-weight="700">Ch<EFBFBD> <11> Leo thang An to<74>n</text>
<text x="80" y="140" fill="#3e4945" font-family="sans-serif" font-size="11"><10>i tho<68>i Socratic</text>
<rect x="80" y="155" width="240" height="40" fill="#f0f5f4" rx="6" stroke="#bec9c4"/>
<rect x="0" y="276" width="400" height="24" fill="rgba(255,255,255,0.72)"/>
</svg>

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300">
<rect width="400" height="300" fill="#f5faf9" rx="12"/>
<rect x="0" y="0" width="400" height="40" fill="rgba(255,255,255,0.55)" stroke="rgba(5,107,88,0.18)"/>
<rect x="16" y="52" width="240" height="232" fill="#0a0f0e" rx="12" stroke="rgba(5,107,88,0.18)"/>
<rect x="268" y="52" width="116" height="232" fill="rgba(255,255,255,0.72)" stroke="rgba(5,107,88,0.22)" rx="12"/>
<text x="280" y="80" fill="#23686c" font-family="sans-serif" font-size="10" font-weight="600"><10> XU<58>T AI</text>
<circle cx="326" cy="110" r="20" fill="#e8a838"/>
<text x="320" y="115" fill="#fff" font-family="sans-serif" font-size="16" font-weight="700">2</text>
<rect x="0" y="276" width="400" height="24" fill="rgba(255,255,255,0.72)" stroke="rgba(5,107,88,0.18)"/>
</svg>

After

Width:  |  Height:  |  Size: 827 B

View File

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 300">
<rect width="400" height="300" fill="#f5faf9" rx="12"/>
<rect x="16" y="16" width="368" height="40" fill="rgba(255,255,255,0.55)" stroke="rgba(5,107,88,0.18)" rx="8"/>
<text x="32" y="42" fill="#23686c" font-family="sans-serif" font-size="14" font-weight="700">Danh s<>ch c<>ng vi<76>c</text>
<rect x="16" y="68" width="110" height="70" fill="rgba(255,255,255,0.55)" stroke="rgba(5,107,88,0.18)" rx="12"/>
<rect x="136" y="68" width="110" height="70" fill="rgba(255,255,255,0.55)" stroke="rgba(5,107,88,0.18)" rx="12"/>
<rect x="256" y="68" width="128" height="70" fill="rgba(255,255,255,0.55)" stroke="rgba(5,107,88,0.18)" rx="12"/>
<rect x="16" y="154" width="180" height="130" fill="rgba(255,255,255,0.55)" stroke="rgba(5,107,88,0.18)" rx="12"/>
<rect x="204" y="154" width="180" height="130" fill="rgba(255,255,255,0.55)" stroke="rgba(5,107,88,0.18)" rx="12"/>
<rect x="0" y="276" width="400" height="24" fill="rgba(255,255,255,0.72)" stroke="rgba(5,107,88,0.18)"/>
<text x="16" y="292" fill="#6f7975" font-family="sans-serif" font-size="10">NFR <20> EMR</text>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

View File

@@ -0,0 +1 @@
function w(n){return[...n].sort((t,e)=>t.relTime!==e.relTime?t.relTime-e.relTime:t.timestamp-e.timestamp)}const f="UIX_Agent_Recorder",a="pending_chunks",p=1,I=1440*60*1e3;function i(n){return new Promise((t,e)=>{n.onsuccess=()=>t(n.result),n.onerror=()=>e(n.error??new Error("IndexedDB request failed"))})}function d(){return new Promise((n,t)=>{const e=indexedDB.open(f,p);e.onerror=()=>t(e.error??new Error("Failed to open UIX WAL IndexedDB")),e.onupgradeneeded=()=>{const o=e.result;if(!o.objectStoreNames.contains(a)){const r=o.createObjectStore(a,{keyPath:"chunkId"});r.createIndex("patientId","patientId",{unique:!1}),r.createIndex("sessionId","sessionId",{unique:!1}),r.createIndex("createdAt","createdAt",{unique:!1})}},e.onsuccess=()=>n(e.result)})}async function k(n){const t=await d();try{const e=t.transaction(a,"readwrite");await i(e.objectStore(a).put(n)),await new Promise((o,r)=>{e.oncomplete=()=>o(),e.onerror=()=>r(e.error??new Error("WAL put transaction failed"))})}finally{t.close()}}async function y(n){const t=await d();try{const e=t.transaction(a,"readwrite");await i(e.objectStore(a).delete(n)),await new Promise((o,r)=>{e.oncomplete=()=>o(),e.onerror=()=>r(e.error??new Error("WAL delete transaction failed"))})}finally{t.close()}}async function _(n){const t=await d();try{const e=t.transaction(a,"readonly"),o=e.objectStore(a).index("patientId"),r=await i(o.getAll(n));return await new Promise((c,s)=>{e.oncomplete=()=>c(),e.onerror=()=>s(e.error??new Error("WAL read transaction failed"))}),r.sort((c,s)=>c.createdAt-s.createdAt)}finally{t.close()}}async function h(){const n=Date.now()-I,t=await d();let e=0;try{const o=t.transaction(a,"readwrite"),r=o.objectStore(a),c=await i(r.getAll());for(const s of c)s.createdAt<n&&(await i(r.delete(s.chunkId)),e+=1);await new Promise((s,l)=>{o.oncomplete=()=>s(),o.onerror=()=>l(o.error??new Error("WAL purge transaction failed"))})}finally{t.close()}return e}async function m(n){return!0}async function u(n){try{await m(n)&&await y(n.chunkId)}catch{}}async function A(n){const t=w(n.chunk.events),e={...n.chunk,events:t};await k(e),self.postMessage({type:"wal_committed",requestId:n.requestId,chunkId:e.chunkId,events:t}),u(e)}async function g(n){const t=await _(n.patientId);for(const e of t)u(e);self.postMessage({type:"wal_recovered",requestId:n.requestId,patientId:n.patientId,chunks:t})}async function b(n){const t=await h();self.postMessage({type:"wal_purge_stale_done",requestId:n.requestId,removedCount:t})}self.onmessage=n=>{const t=n.data;if(!(t!=null&&t.requestId))return;const e=r=>{self.postMessage({type:"wal_error",requestId:t.requestId,message:r})};(async()=>{switch(t.type){case"wal_flush":await A(t);break;case"wal_recover_patient":await g(t);break;case"wal_purge_stale":await b(t);break;default:e("Unknown WAL worker message type")}})().catch(r=>{e(r instanceof Error?r.message:"WAL worker operation failed")})};

View File

@@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 480" role="img">
<defs>
<radialGradient id="us-bg" cx="50%" cy="45%" r="55%">
<stop offset="0%" stop-color="#2a3533"/>
<stop offset="100%" stop-color="#0a0f0e"/>
</radialGradient>
<filter id="noise">
<feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="4" stitchTiles="stitch"/>
<feColorMatrix type="saturate" values="0"/>
<feBlend in="SourceGraphic" mode="multiply"/>
</filter>
</defs>
<rect width="640" height="480" fill="url(#us-bg)"/>
<ellipse cx="320" cy="240" rx="220" ry="170" fill="#1a2422" opacity="0.6"/>
<path d="M180 200 Q260 120 400 180 T520 280 Q420 360 280 340 T160 260 Z" fill="#3d4a47" opacity="0.5" filter="url(#noise)"/>
<path d="M200 220 Q300 160 420 210 T500 300 Q380 380 260 350 T190 270 Z" fill="#5a6b66" opacity="0.35"/>
<ellipse cx="340" cy="230" rx="45" ry="28" fill="#8a9a94" opacity="0.25"/>
<text x="20" y="30" fill="#76c8b1" font-family="monospace" font-size="14">SI<EFBFBD>U <20>M <20> KH<4B>P G<>I</text>
<text x="20" y="460" fill="#6f7975" font-family="monospace" font-size="12">12.5 MHz <20> Gain 62</text>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,20 @@
<!doctype html>
<html lang="vi">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lumina MSK — Không gian Lâm sàng</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Manrope:wght@600;700&family=Work+Sans:wght@400;500;600&display=swap"
rel="stylesheet"
/>
<script type="module" crossorigin src="/assets/index-BCa3bqPy.js"></script>
<link rel="modulepreload" crossorigin href="/assets/vendor-react-BWW3uBCb.js">
<link rel="stylesheet" crossorigin href="/assets/index-LQC2ade3.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,30 @@
# Stage 1: Build
FROM node:26-bookworm-slim AS builder
WORKDIR /app
# Copy package files and install scripts first (better cache)
COPY package.json package-lock.json* ./
COPY scripts/ ./scripts/
RUN npm ci
# Copy source and build
COPY . .
RUN npm run build
# Stage 2: Runtime (nginx)
FROM nginx:alpine
# Remove default nginx static files
RUN rm -rf /usr/share/nginx/html/*
# Copy built assets from builder
COPY --from=builder /app/dist /usr/share/nginx/html
# Custom nginx config for SPA routing + caching
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -1,13 +1,25 @@
# 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.
#
# Deployment note: the backend servers (main.py, cv_inference_server.py) enforce
# CORS via the CORS_ORIGINS environment variable. Make sure the origins listed here
# (especially the production frontend origin) are included in that env var.
VITE_USE_BACKEND_SEGMENTATION: "true"
VITE_SEGMENT_API_BASE: ""
# Leave empty in dev — Vite proxy handles /api/* → backend.
# Set to your production API origin (e.g. "https://api.your-domain.com") for builds.
VITE_API_BASE_URL: "https://api.lumina-msk.io.vn"
# Leave empty in dev — Vite proxy handles /api/test/* → CV inference server.
# Set to your production CV inference origin (e.g. "https://cv.your-domain.com") for builds.
VITE_SEGMENT_API_BASE: "https://cv.lumina-msk.io.vn"
# Comma-separated list of origins the frontend may be served from.
# Used by deployment scripts to populate backend CORS_ORIGINS env var.
VITE_ALLOWED_ORIGINS: "https://lumina-msk.io.vn,https://www.lumina-msk.io.vn"
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"
VITE_DISTRIBUTE_DIRRECTORY: "workspace/sprint_1_2/CODEBASE/frontend/distribute_asset"

View File

@@ -1,16 +1,24 @@
import React, { Suspense } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import { PatientStoreProvider } from './lib/patientStore';
import PatientWorklistPage from './pages/PatientWorklistPage';
import ClinicalWorkspacePage from './pages/ClinicalWorkspacePage';
const PatientWorklistPage = React.lazy(() => import('./pages/PatientWorklistPage'));
const ClinicalWorkspacePage = React.lazy(() => import('./pages/ClinicalWorkspacePage'));
function LoadingFallback() {
return <div style={{ padding: '2rem', textAlign: 'center' }}>Loading</div>;
}
export default function App() {
return (
<PatientStoreProvider>
<Routes>
<Route path="/" element={<PatientWorklistPage />} />
<Route path="/workspace/:patientId" element={<ClinicalWorkspacePage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<Suspense fallback={<LoadingFallback />}>
<Routes>
<Route path="/" element={<PatientWorklistPage />} />
<Route path="/workspace/:patientId" element={<ClinicalWorkspacePage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
</PatientStoreProvider>
);
}

View File

@@ -19,8 +19,12 @@ export async function probeCapabilities(): Promise<CapabilityReport> {
if (!secureContext) {
notes.push('Not a secure context. Use https:// or http://localhost.');
}
if (location.hostname === '127.0.0.1') {
notes.push('OPFS is origin-scoped: 127.0.0.1 and localhost use separate storage.');
if (opfs && location.protocol !== 'https:' && location.hostname !== 'localhost') {
notes.push(
'OPFS is origin-scoped: caches are isolated per scheme+host+port. ' +
'Models downloaded on one origin (e.g. localhost, 127.0.0.1, preview domains) ' +
'are not available on another. Expect a full re-download when the origin changes.',
);
}
const ready = webgpu && opfs && worker && secureContext;

View File

@@ -14,13 +14,13 @@ export const BOOTSTRAP_DECODE: DecodeParams = {
maxTokens: MIN_COT_MAX_TOKENS,
};
function envFlag(name: string, defaultValue: boolean): boolean {
const raw = import.meta.env[name];
if (raw === undefined || raw === '') {
return defaultValue;
}
return raw === '1' || raw.toLowerCase() === 'true';
}
// function envFlag(name: string, defaultValue: boolean): boolean {
// const raw = import.meta.env[name];
// if (raw === undefined || raw === '') {
// return defaultValue;
// }
// return raw === '1' || raw.toLowerCase() === 'true';
// }
/** Try local Gemma worker when OPFS model is available. Falls back to mock replies otherwise. */
export function useLocalLlmWhenAvailable(): boolean {
@@ -33,7 +33,19 @@ export function useMockAgentTools(): boolean {
}
export function clinicalChatBffBaseUrl(): string {
return import.meta.env.VITE_API_BASE_URL ?? '';
const raw = import.meta.env.VITE_API_BASE_URL;
const trimmed = raw != null ? String(raw).trim() : '';
if (trimmed) {
return trimmed;
}
if (import.meta.env.DEV) {
return '';
}
console.warn(
'[clinicalChatConfig] VITE_API_BASE_URL is not set. ' +
'Clinical chat BFF calls will fail. Set it in config/frontend.config.yaml for production builds.',
);
return '';
}
const DEFAULT_OLLAMA_CHAT_URL = '/api/ollama-chat/api/chat';

View File

@@ -216,7 +216,7 @@ export async function runDirectChatTurn(
promptOptions,
decode,
input.onToken,
input.onSegmentStart,
// input.onSegmentStart,
);
if (signal?.aborted) {

View File

@@ -71,7 +71,7 @@ export interface SegmentationApiResult {
}
/** Resolve API base: dev uses Vite proxy (empty); preview/build targets local test server. */
/** Resolve API base: dev uses Vite proxy (empty); production uses configured URL. */
export function getSegmentApiBase(): string {
const configured = import.meta.env.VITE_SEGMENT_API_BASE;
if (configured != null && String(configured).trim() !== '') {
@@ -80,7 +80,11 @@ export function getSegmentApiBase(): string {
if (import.meta.env.DEV) {
return '';
}
return 'http://127.0.0.1:8001';
console.warn(
'[segmentationApi] VITE_SEGMENT_API_BASE is not set. ' +
'Segmentation API calls will fail. Set it in config/frontend.config.yaml for production builds.',
);
return '';
}
/** ML inference always uses the test proxy / production API — mock path detached. */

File diff suppressed because one or more lines are too long

View File

@@ -24,6 +24,30 @@ const MODAL_OLLAMA_TARGET =
frontendConfig.VITE_MODAL_OLLAMA_TARGET ??
'https://dtj-tran--ollama-gemma4-e4b-ollamaserver-web.modal.run';
// API base URLs from YAML — empty in dev so Vite proxy handles them.
const apiBaseUrl = (frontendConfig.VITE_API_BASE_URL ?? '').trim();
const segmentApiBase = (frontendConfig.VITE_SEGMENT_API_BASE ?? '').trim();
// Resolve build output directory from YAML config, falling back to 'dist'
function findProjectRoot(startDir: string): string {
let current = startDir;
while (current !== path.parse(current).root) {
if (fs.existsSync(path.join(current, '.git'))) {
return current;
}
current = path.dirname(current);
}
return startDir;
}
const projectRoot = findProjectRoot(__dirname);
const configuredOutDir = frontendConfig.VITE_DISTRIBUTE_DIRRECTORY;
const buildOutDir = configuredOutDir
? path.isAbsolute(configuredOutDir)
? configuredOutDir
: path.resolve(projectRoot, configuredOutDir)
: path.resolve(__dirname, 'dist');
export default defineConfig({
plugins: [react()],
worker: {
@@ -33,7 +57,7 @@ export default defineConfig({
alias: {
'@vkist/agent-runtime': path.resolve(
__dirname,
'../../ml/implementation/nlp/agent_runtime/src/index.ts',
'../../ml/implementation/nlp/agent_runtime/src'
),
},
},
@@ -41,7 +65,7 @@ export default defineConfig({
host: '127.0.0.1',
port: 5173,
strictPort: true,
// Keep HMR WebSocket on the same host/port as the page (avoids localhost vs 127.0.0.1 mismatch).
// Keep HMR WebSocket consistent (avoid localhost vs 127.0.0.1 mismatch).
hmr: {
host: '127.0.0.1',
port: 5173,
@@ -58,18 +82,31 @@ export default defineConfig({
proxyTimeout: 300_000,
},
'/api/test': {
target: 'http://127.0.0.1:8001',
target: segmentApiBase || 'http://127.0.0.1:8001',
changeOrigin: true,
timeout: 300_000,
proxyTimeout: 300_000,
},
'/api': {
target: 'http://127.0.0.1:8001',
target: apiBaseUrl || 'http://127.0.0.1:8001',
changeOrigin: true,
timeout: 300_000,
proxyTimeout: 300_000,
},
},
},
build: {
outDir: buildOutDir,
rollupOptions: {
output: {
manualChunks: {
'vendor-react': ['react', 'react-dom', 'react-router-dom'],
'vendor-ml': ['@litert-lm/core', '@mediapipe/tasks-genai'],
'agent-runtime': ['@vkist/agent-runtime'],
},
},
},
chunkSizeWarningLimit: 250,
},
define: defineVars,
});