feat: support multiple camera previews and concurrent agent code navigation

This commit is contained in:
2026-10-20 10:10:48 +09:00
parent c17763f59d
commit af54e875df
13 changed files with 525 additions and 160 deletions
@@ -0,0 +1,113 @@
export function createCameraPreview({ root, baseUrl, getState, onAvailable }) {
const cameras = new Map();
let enabled = false;
let timer = null;
function sources() {
const { state, receivedAt } = getState();
if (!enabled || Date.now() - receivedAt >= 3000) return [];
const list = Array.isArray(state?.cameras) ? state.cameras
: state?.topic_status?.camera_image?.available ? [{ id: 'legacy', name: 'Camera', available: true }] : [];
const seen = new Set();
return list.filter(item => {
if (!item?.available || !/^[a-zA-Z0-9_-]{1,64}$/.test(item.id) || seen.has(item.id)) return false;
seen.add(item.id);
return true;
}).slice(0, 8);
}
function clearFrame(camera) {
if (camera.url) URL.revokeObjectURL(camera.url);
camera.url = null;
camera.image.removeAttribute('src');
}
function remove(id, camera) {
cameras.delete(id);
camera.controller?.abort();
clearFrame(camera);
camera.element.remove();
}
async function frame(id, camera) {
if (camera.controller || Date.now() < camera.retryAt || document.hidden) return;
const controller = new AbortController();
camera.controller = controller;
const timeout = window.setTimeout(() => controller.abort(), 2000);
try {
const query = id === 'legacy' ? '' : `?camera_id=${encodeURIComponent(id)}`;
const response = await fetch(`${baseUrl}/camera-frame${query}`, { cache: 'no-store', signal: controller.signal });
if (!response.ok) throw new Error('Camera frame unavailable');
const blob = await response.blob();
if (controller.signal.aborted || document.hidden || cameras.get(id) !== camera || !sources().some(item => item.id === id)) return;
const previous = camera.url;
camera.url = URL.createObjectURL(blob);
camera.image.src = camera.url;
camera.retryAt = 0;
if (previous) URL.revokeObjectURL(previous);
} catch (_) {
if (cameras.get(id) === camera) {
camera.retryAt = Date.now() + 5000;
clearFrame(camera);
}
} finally {
window.clearTimeout(timeout);
if (camera.controller === controller) camera.controller = null;
}
}
function refresh() {
const live = sources();
const ids = new Set(live.map(item => item.id));
for (const [id, camera] of cameras) if (!ids.has(id)) remove(id, camera);
for (const item of live) {
let camera = cameras.get(item.id);
if (!camera) {
const element = document.createElement('div');
element.className = 'camera-preview-item';
element.dataset.cameraId = item.id;
const label = document.createElement('div');
label.className = 'camera-preview-label';
const image = document.createElement('img');
image.className = 'camera-preview-image';
element.append(label, image);
root.append(element);
camera = { element, label, image, url: null, controller: null, retryAt: 0 };
image.addEventListener('error', () => {
if (cameras.get(item.id) === camera) {
clearFrame(camera);
camera.retryAt = Date.now() + 5000;
}
});
cameras.set(item.id, camera);
}
camera.label.textContent = item.name || item.id;
camera.image.alt = item.name || item.id;
void frame(item.id, camera);
}
onAvailable(enabled && cameras.size > 0);
}
function visibilityChanged() {
if (document.hidden) for (const camera of cameras.values()) camera.controller?.abort();
else refresh();
}
document.addEventListener('visibilitychange', visibilityChanged);
function setEnabled(value) {
enabled = value;
if (timer) window.clearInterval(timer);
timer = null;
refresh();
if (enabled) timer = window.setInterval(refresh, 200);
}
return {
setEnabled,
refresh,
dispose() {
setEnabled(false);
document.removeEventListener('visibilitychange', visibilityChanged);
},
};
}
+28 -76
View File
@@ -61,14 +61,29 @@
#cameraPreviewWrap.is-visible { display:block; }
#cameraToggle { display:grid; place-items:center; width:34px; flex:0 0 34px; padding:0; }
#cameraPreviewWrap.is-collapsed { background:transparent; border-color:transparent; box-shadow:none; }
#cameraPreview {
#cameraPreviewList {
max-height:min(60vh, 600px);
overflow-y:auto;
scrollbar-width:thin;
scrollbar-color:rgba(148,163,184,.65) transparent;
}
.camera-preview-item + .camera-preview-item { border-top:1px solid rgba(255,255,255,.18); }
.camera-preview-label {
padding:5px 8px;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
color:rgba(255,255,255,.85);
font:11px sans-serif;
}
.camera-preview-image {
display:block;
width:100%;
aspect-ratio:16 / 9;
object-fit:contain;
background:#111520;
}
#cameraPreviewWrap.is-collapsed #cameraPreview { display:none; }
#cameraPreviewWrap.is-collapsed #cameraPreviewList { display:none; }
#cameraPreviewToggle {
position:absolute;
left:0;
@@ -426,7 +441,7 @@
<button id="miniMapToggle" title="미니맵 접기/펼치기" data-i18n-title="미니맵 접기/펼치기" aria-label="미니맵 접기" data-i18n-aria-label="미니맵 접기">▾</button>
<div id="cameraPreviewWrap">
<button id="cameraPreviewToggle" type="button" title="카메라 화면 접기/펼치기" data-i18n-title="카메라 화면 접기/펼치기" aria-label="카메라 화면 접기" data-i18n-aria-label="카메라 화면 접기" aria-expanded="true"><span id="cameraPreviewChevron" aria-hidden="true">▾</span></button>
<img id="cameraPreview" alt="실시간 카메라 화면" data-i18n-alt="실시간 카메라 화면" />
<div id="cameraPreviewList"></div>
</div>
<div id="keyGuide">
<div data-i18n="i 전진 · , 후진 · j/l 회전 · k 정지">i 전진 · , 후진 · j/l 회전 · k 정지</div>
@@ -463,6 +478,7 @@
<script type="module" src="./locale.js"></script>
<script type="module">
import { t, setText } from './locale.js';
import { createCameraPreview } from './camera-preview.js';
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
@@ -502,7 +518,7 @@ const miniMap = document.getElementById('miniMap');
const miniCtx = miniMap.getContext('2d');
const miniMapToggle = document.getElementById('miniMapToggle');
const cameraPreviewWrap = document.getElementById('cameraPreviewWrap');
const cameraPreview = document.getElementById('cameraPreview');
const cameraPreviewList = document.getElementById('cameraPreviewList');
const cameraPreviewToggle = document.getElementById('cameraPreviewToggle');
const cameraToggleButton = document.getElementById('cameraToggle');
const topRightControls = document.getElementById('topRightControls');
@@ -937,12 +953,7 @@ let pointCloudRefreshTimer = null;
let savedMapPointCloudLoaded = false;
let sensorPointCloudRequestInFlight = false;
let sensorPointCloudRefreshTimer = null;
let cameraFrameRequestInFlight = false;
let cameraFrameRetryAt = 0;
let cameraFrameRefreshTimer = null;
let cameraFrameObjectUrl = null;
let cameraPreviewEnabled = ENABLE_CAMERA_PREVIEW;
let cameraFrameAbortController = null;
let pathVisible = false;
// SLAM always renders its live scan, including the forward beam. Navigation
// keeps the sensor opt-in so a saved-map view starts cleanly.
@@ -2087,84 +2098,27 @@ async function refreshSensorPointCloud() {
}
}
function setCameraPreviewAvailable(available) {
available = available && cameraPreviewEnabled;
cameraPreviewWrap?.classList.toggle('is-visible', available);
if (available) return;
if (cameraFrameObjectUrl) {
URL.revokeObjectURL(cameraFrameObjectUrl);
cameraFrameObjectUrl = null;
}
if (cameraPreview) cameraPreview.removeAttribute('src');
}
function hasLiveCameraState() {
return latestSlamState?.topic_status?.camera_image?.available === true
&& Date.now() - latestSlamStateReceivedAt < 3000;
}
async function refreshCameraFrame() {
if (!cameraPreviewEnabled || cameraFrameRequestInFlight || document.hidden) return;
// Reuse websocket topic status instead of probing an absent camera with HTTP.
if (!hasLiveCameraState()) {
setCameraPreviewAvailable(false);
return;
}
if (Date.now() < cameraFrameRetryAt) return;
cameraFrameRequestInFlight = true;
const controller = new AbortController();
cameraFrameAbortController = controller;
const timeout = window.setTimeout(() => controller.abort(), 2000);
try {
const response = await fetch(`${BACKEND_URL}/camera-frame`, { cache: 'no-store', signal: controller.signal });
if (!response.ok) {
cameraFrameRetryAt = Date.now() + 5000;
setCameraPreviewAvailable(false);
return;
}
const frame = await response.blob();
if (controller.signal.aborted || !cameraPreviewEnabled || !hasLiveCameraState() || document.hidden) {
setCameraPreviewAvailable(false);
return;
}
cameraFrameRetryAt = 0;
const nextUrl = URL.createObjectURL(frame);
const previousUrl = cameraFrameObjectUrl;
cameraFrameObjectUrl = nextUrl;
if (cameraPreview) cameraPreview.src = nextUrl;
if (previousUrl) URL.revokeObjectURL(previousUrl);
} catch (_) {
if (!controller.signal.aborted) cameraFrameRetryAt = Date.now() + 5000;
setCameraPreviewAvailable(false);
} finally {
window.clearTimeout(timeout);
cameraFrameAbortController = null;
cameraFrameRequestInFlight = false;
}
}
const cameraPreviewController = createCameraPreview({
root: cameraPreviewList,
baseUrl: BACKEND_URL,
getState: () => ({ state: latestSlamState, receivedAt: latestSlamStateReceivedAt }),
onAvailable: available => cameraPreviewWrap.classList.toggle('is-visible', available),
});
function setCameraPreviewEnabled(enabled) {
cameraPreviewEnabled = ENABLE_CAMERA_PREVIEW && enabled;
cameraToggleButton.classList.toggle('active', cameraPreviewEnabled);
cameraToggleButton.setAttribute('aria-pressed', String(cameraPreviewEnabled));
if (cameraFrameRefreshTimer) window.clearInterval(cameraFrameRefreshTimer);
cameraFrameRefreshTimer = null;
cameraPreviewController.setEnabled(cameraPreviewEnabled);
if (!cameraPreviewEnabled) {
cameraFrameAbortController?.abort();
setCameraPreviewAvailable(false);
return;
}
cameraFrameRetryAt = 0;
cameraPreviewWrap.classList.remove('is-collapsed');
cameraPreviewToggle.setAttribute('aria-expanded', 'true');
cameraPreviewToggle.setAttribute('aria-label', t('카메라 화면 접기'));
void refreshCameraFrame();
cameraFrameRefreshTimer = window.setInterval(() => void refreshCameraFrame(), 200);
}
cameraToggleButton.addEventListener('click', () => setCameraPreviewEnabled(!cameraPreviewEnabled));
cameraPreview?.addEventListener('load', () => setCameraPreviewAvailable(hasLiveCameraState()));
cameraPreview?.addEventListener('error', () => setCameraPreviewAvailable(false));
function drawMiniMap(state) {
pendingMiniMapState = state;
@@ -3299,9 +3253,7 @@ window.addEventListener('beforeunload', () => {
if (pointCloudRefreshTimer) window.clearInterval(pointCloudRefreshTimer);
if (sensorPointCloudRefreshTimer) window.clearInterval(sensorPointCloudRefreshTimer);
if (miniMapRenderTimer) window.clearTimeout(miniMapRenderTimer);
if (cameraFrameRefreshTimer) window.clearInterval(cameraFrameRefreshTimer);
cameraFrameAbortController?.abort();
if (cameraFrameObjectUrl) URL.revokeObjectURL(cameraFrameObjectUrl);
cameraPreviewController.dispose();
});
function connectControlSocket() {
+1 -1
View File
@@ -3690,7 +3690,7 @@ function LocalPcWorkspaceMockup({
}
}
const openWorkspaceAgentCodeReference = async (reference: AgentCodeReference, conversationCurrent: () => boolean): Promise<void> => {
if (agentActionControllerRef.current || agentApplying) throw new Error('진행 중인 에이전트 작업이 끝난 뒤 열어주세요.')
if (agentApplying) throw new Error('진행 중인 에이전트 작업이 끝난 뒤 열어주세요.')
validateCodeReference(reference)
const scope = agentNavigationScope
const isCurrent = (): boolean => conversationCurrent() && agentNavigationRef.current.scope === scope && agentNavigationRef.current.visible
@@ -429,6 +429,8 @@ function AgentMessages({
)
}
const codeNavigationBusy = messages.some(message => message.applyState === 'applying')
return (
<div className="grid gap-3">
{messages.map((message, index) => message.role === 'user' ? (
@@ -447,7 +449,7 @@ function AgentMessages({
<ProgressiveChatResponse message={message} dark={dark} collapseOnSignal={index < messages.length - 1} collapseSignal={messages.length} footerAction={<MessageCopyButton content={agentMessageCopyText(message)} dark={dark} />}>
{message.plan && (
<>
{!!message.plan.codeReferences?.length && <AgentCodeReferences references={message.plan.codeReferences} states={message.codeReferenceStates} dark={dark} busy={busy} onOpen={onOpenCodeReference ? id => onOpenCodeReference(message.id, id) : undefined} />}
{!!message.plan.codeReferences?.length && <AgentCodeReferences references={message.plan.codeReferences} states={message.codeReferenceStates} dark={dark} busy={codeNavigationBusy} onOpen={onOpenCodeReference ? id => onOpenCodeReference(message.id, id) : undefined} />}
{!!message.plan.evidence?.length && (
<details className={['border-t pt-2 text-[11px]', dark ? 'border-white/10 text-white/55' : 'border-[#ddd6fe] text-[#64748b]'].join(' ')}>
<summary className="cursor-pointer select-none">{t("조회 근거")} · {message.plan.evidence.length}</summary>
@@ -460,11 +460,11 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
}
const openCodeReference = async (messageId: string, referenceId: string, open: (reference: AgentCodeReference, isCurrent: () => boolean) => Promise<void>): Promise<void> => {
if (navigatingRef.current || applyingRef.current || abortControllerRef.current || messageStateRef.current.session !== session) return
if (navigatingRef.current || applyingRef.current || messageStateRef.current.session !== session) return
const message = messageStateRef.current.messages.find(message => message.id === messageId)
const reference = message?.plan?.codeReferences?.find(reference => reference.id === referenceId)
if (!reference) return
const latestMessageId = messageStateRef.current.messages.at(-1)?.id
const latestUserMessageId = messageStateRef.current.messages.findLast(message => message.role === 'user')?.id
const isCurrent = (): boolean => messageStateRef.current.session === session && messageStateRef.current.messages.some(message => message.id === messageId)
const update = (state: AgentCodeReferenceState): void => {
if (isCurrent()) updateMessages(current => current.map(message => message.id === messageId ? { ...message, codeReferenceStates: { ...message.codeReferenceStates, [referenceId]: state } } : message))
@@ -472,7 +472,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
navigatingRef.current = true
update({ status: 'opening' })
try {
await open(reference, () => isCurrent() && messageStateRef.current.messages.at(-1)?.id === latestMessageId && !abortControllerRef.current)
await open(reference, () => isCurrent() && messageStateRef.current.messages.findLast(message => message.role === 'user')?.id === latestUserMessageId && !applyingRef.current)
update({ status: 'opened' })
} catch (error) {
update({ status: 'error', error: error instanceof Error ? error.message : '코드 위치를 열지 못했습니다. 다시 분석을 요청해주세요.' })