feat: support multiple camera previews and concurrent agent code navigation
This commit is contained in:
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 : '코드 위치를 열지 못했습니다. 다시 분석을 요청해주세요.' })
|
||||
|
||||
Reference in New Issue
Block a user