Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
8fc7178
feat: add frontend observability probes
Jun 18, 2026
b965e0b
Merge remote-tracking branch 'origin/lex-main' into observability
Jun 18, 2026
dd729e8
Tune browser audio tail thresholds
Jun 24, 2026
f3b8d09
Fix frontend observability review issues
Jun 24, 2026
87dab27
Fix frontend observability review issues
Jun 24, 2026
65bcc1d
Document legacy participant identity attr
Jun 24, 2026
48d9b05
Use VAD speech end observability event
Jun 25, 2026
62f323b
Bundle frontend VAD runtime assets
Jun 26, 2026
01bfd33
Reuse frontend observability attribute constants
Jun 26, 2026
2dc65ea
Ignore bundled VAD assets in Prettier
Jun 26, 2026
aff48ea
Stop tracking generated VAD assets
Jun 26, 2026
ec4619c
Sync VAD assets before frontend build
Jun 26, 2026
9955801
Use observability constants for browser source track metadata
Jun 26, 2026
f2d3cc3
Use VAD speech frame time for browser audio end
Jun 26, 2026
5c6f09b
Fix frontend observability cleanup edge cases
Jun 26, 2026
874f59b
Fix room input stop lifecycle
jinfeng66 Jun 30, 2026
affb249
Fix playback observability review issues
jinfeng66 Jun 30, 2026
08bc4ce
Harden frontend observability handling
jinfeng66 Jun 30, 2026
a68074b
Address frontend observability follow-up review
jinfeng66 Jun 30, 2026
f3031bf
Fix playback error diagnostics refresh
jinfeng66 Jun 30, 2026
af72b6c
Gate playback observer when observability is disabled
jinfeng66 Jun 30, 2026
94dfdcb
Reduce frontend observability follow-up noise
jinfeng66 Jun 30, 2026
3df5f86
Clean up frontend observability review nits
jinfeng66 Jun 30, 2026
f3159a9
Limit room input stop to selected server devices
jinfeng66 Jun 30, 2026
eba3d05
Document room input control URL contract
jinfeng66 Jul 1, 2026
eac6132
Share input device resolution logic
jinfeng66 Jul 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,7 @@
# Only create `agent-starter-react/.env.local` for standalone frontend
# development where this repository is launched directly with `pnpm dev`.
# In that case, define only the variables needed for that standalone run.
#
# `OBSERVABILITY_ENABLED=1` uses the same unified switch as the backend. When
# enabled by the LexVoice runtime, browser-side probes publish LiveKit data
# packets for the local observability report.
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,7 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts

# generated local VAD runtime assets
/public/onnxruntime-web/
/public/vad-web/
3 changes: 2 additions & 1 deletion .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ node_modules/
pnpm-lock.yaml
.next/
.env*

public/onnxruntime-web/
public/vad-web/


62 changes: 22 additions & 40 deletions app-config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
import {
type RoleInputDeviceOptions,
normalizeInputSource,
resolveRoleInputDevices,
usesServerRoomInputDevice,
} from './lib/input-device-config';

export interface VideoTrackConfig {
id: string;
label: string;
Expand Down Expand Up @@ -49,6 +56,7 @@ export interface AppConfig {
showAudioFilterDebug?: boolean;
debugAudio?: boolean;
debugVideo?: boolean;
observabilityEnabled?: boolean;

// 全局调试配置
enableGlobalDebug?: boolean; // 全局调试开关,控制所有调试信息的显示
Expand All @@ -74,16 +82,9 @@ const ROOM_INPUT_VIDEO_TRACK_NAME =
'room_video';
const BROWSER_VIDEO_TRACK_NAME = 'browser_video_track';

const DEFAULT_ROLE_INPUT_DEVICE = 'xunfei';
const VALID_INPUT_DEVICES = new Set(['xunfei', 'generic', 'primebot', 'browser']);
const SERVER_ROOM_INPUT_DEVICES = new Set(['xunfei', 'generic']);
export { normalizeInputSource };

export interface InputDeviceConfigOptions {
inputSource?: string | null;
audioInputDevice?: string | null;
visionInputDevice?: string | null;
outputDevice?: string | null;
}
export type InputDeviceConfigOptions = RoleInputDeviceOptions;

export interface InputDeviceConfig {
inputSource: string;
Expand All @@ -98,43 +99,23 @@ export interface InputDeviceConfig {
showDefaultCameraPreview: boolean;
}

export function normalizeInputSource(inputSource?: string | null) {
const normalized = (inputSource || '').trim().toLowerCase();
return normalized || 'browser';
}

function normalizeRoleInputDevice(inputDevice: string | null | undefined, fallback: string) {
const normalized = (inputDevice || '').trim().toLowerCase();
if (VALID_INPUT_DEVICES.has(normalized)) {
return normalized;
}
return fallback;
}

function usesServerRoomInputDevice(inputDevice: string) {
return SERVER_ROOM_INPUT_DEVICES.has(inputDevice);
}

export function resolveInputDeviceConfig({
inputSource,
audioInputDevice,
visionInputDevice,
outputDevice,
}: InputDeviceConfigOptions = {}): InputDeviceConfig {
const normalizedInputSource = normalizeInputSource(inputSource);
const isMixedInputSource = normalizedInputSource === 'mixed';
const baseInputDevice = isMixedInputSource
? DEFAULT_ROLE_INPUT_DEVICE
: normalizeRoleInputDevice(normalizedInputSource, DEFAULT_ROLE_INPUT_DEVICE);
const resolvedAudioInputDevice = isMixedInputSource
? normalizeRoleInputDevice(audioInputDevice, baseInputDevice)
: baseInputDevice;
const resolvedVisionInputDevice = isMixedInputSource
? normalizeRoleInputDevice(visionInputDevice, baseInputDevice)
: baseInputDevice;
const resolvedOutputDevice = isMixedInputSource
? normalizeRoleInputDevice(outputDevice, baseInputDevice)
: baseInputDevice;
const {
inputSource: normalizedInputSource,
audioInputDevice: resolvedAudioInputDevice,
visionInputDevice: resolvedVisionInputDevice,
outputDevice: resolvedOutputDevice,
} = resolveRoleInputDevices({
inputSource,
audioInputDevice,
visionInputDevice,
outputDevice,
});
const usesBrowserRawAudioInput = resolvedAudioInputDevice === 'browser';
const usesBrowserRawVideoInput = resolvedVisionInputDevice === 'browser';
const usesBrowserRawMediaInput = usesBrowserRawAudioInput || usesBrowserRawVideoInput;
Expand Down Expand Up @@ -259,6 +240,7 @@ export const APP_CONFIG_DEFAULTS: AppConfig = {
showAudioFilterDebug: process.env.NEXT_PUBLIC_SHOW_AUDIO_DEBUG === 'true' || false, // 是否显示音频过滤调试组件
debugAudio: false,
debugVideo: false,
observabilityEnabled: false,

// 全局调试配置
enableGlobalDebug: process.env.NEXT_PUBLIC_ENABLE_GLOBAL_DEBUG === 'true' || false, // 全局调试开关
Expand Down
98 changes: 95 additions & 3 deletions app/api/session/stop/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import {
deriveSessionIdFromLiveKitRoomName,
isValidConnectionRoomId,
} from '@/lib/connection-room-id';
import { resolveLiveKitHttpUrl } from '@/lib/session-stop';
import {
resolveRoomInputStopUrls as resolveConfiguredRoomInputStopUrls,
resolveLiveKitHttpUrl,
} from '@/lib/session-stop';
import {
markRoomSessionStopped,
markRoomSessionStopping,
Expand All @@ -22,11 +25,13 @@ const AGENT_WORKER_READINESS_TIMEOUT_MS = readPositiveIntEnv(
10_000
);
const AGENT_WORKER_LOG_TAIL_BYTES = readPositiveIntEnv('AGENT_WORKER_LOG_TAIL_BYTES', 256 * 1024);
const ROOM_INPUT_STOP_TIMEOUT_MS = readPositiveIntEnv('ROOM_INPUT_STOP_TIMEOUT_MS', 3_000);

type StopResult = {
target: string;
ok: boolean;
skipped?: boolean;
fatal?: boolean;
status?: number;
error?: string;
dispatch_ids?: string[];
Expand Down Expand Up @@ -134,6 +139,34 @@ function shouldWaitForLocalAgentWorkerReadiness(): boolean {
return inputSource !== 'browser' && !(inputSource === 'mixed' && usesBrowserOnlyMixedInput());
}

function shouldStopRoomInput(): boolean {
return shouldWaitForLocalAgentWorkerReadiness();
}

function resolveRoomInputStopUrls(): string[] {
if (!shouldStopRoomInput()) {
return [];
}

return resolveConfiguredRoomInputStopUrls({
inputSource: readStopInputSource(),
audioInputDevice: readStopRoleDevice(
'ROOM_AUDIO_INPUT_DEVICE',
'NEXT_PUBLIC_ROOM_AUDIO_INPUT_DEVICE'
),
visionInputDevice: readStopRoleDevice(
'ROOM_VISION_INPUT_DEVICE',
'NEXT_PUBLIC_ROOM_VISION_INPUT_DEVICE'
),
roomAudioInputUrl: readStopEnv('ROOM_AUDIO_INPUT_URL'),
roomVisionInputUrl: readStopEnv('ROOM_VISION_INPUT_URL'),
roomInputUrl: readStopEnv('ROOM_INPUT_URL'),
frontdeskInputParticipantUrl: readStopEnv('FRONTDESK_INPUT_PARTICIPANT_URL'),
faceServiceUrl: readStopEnv('FACE_SERVICE_URL'),
genericCameraParticipantUrl: readStopEnv('GENERIC_CAMERA_PARTICIPANT_URL'),
});
}

function resolveLocalLiveKitServerLogPath(): string {
const runLogDir = process.env.LEXVOICE_RUN_LOG_DIR?.trim();
return runLogDir ? path.join(runLogDir, 'server.log') : '';
Expand Down Expand Up @@ -272,16 +305,69 @@ async function waitForPendingDispatches(roomName: string, sessionId: string): Pr
return { target: 'agent_dispatch_barrier', ok: true };
}

async function postRoomInputStop(
stopUrl: string,
roomName: string,
sessionId: string
): Promise<StopResult> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), ROOM_INPUT_STOP_TIMEOUT_MS);
try {
const response = await fetch(stopUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ room_name: roomName, session_id: sessionId }),
signal: controller.signal,
});

if (response.ok) {
return { target: 'room_input', ok: true, status: response.status };
}

return {
target: 'room_input',
ok: false,
fatal: false,
status: response.status,
error: `room-input stop returned HTTP ${response.status}`,
};
} catch (error) {
return {
target: 'room_input',
ok: false,
fatal: false,
error: error instanceof Error ? error.message : String(error),
};
} finally {
clearTimeout(timeout);
}
}

async function stopRoomInput(roomName: string, sessionId: string): Promise<StopResult[]> {
const stopUrls = resolveRoomInputStopUrls();
if (stopUrls.length === 0) {
return [{ target: 'room_input', ok: true, skipped: true }];
}

return Promise.all(stopUrls.map((stopUrl) => postRoomInputStop(stopUrl, roomName, sessionId)));
}

async function runRemoteSessionCleanup(
roomName: string,
sessionId: string,
dispatchResult: StopResult,
dispatchIds: string[]
): Promise<{ results: StopResult[]; failures: StopResult[] }> {
const dispatchBarrierResult = await waitForPendingDispatches(roomName, sessionId);
const roomInputResults = await stopRoomInput(roomName, sessionId);
const liveKitRoomResult = await deleteLiveKitRoom(roomName);
const agentWorkerReadinessResult = await waitForLocalAgentWorkerReadiness();
const cleanupResults = [dispatchBarrierResult, liveKitRoomResult, agentWorkerReadinessResult];
const cleanupResults = [
dispatchBarrierResult,
...roomInputResults,
liveKitRoomResult,
agentWorkerReadinessResult,
];
const results = [
{
target: 'session_registry',
Expand All @@ -291,12 +377,18 @@ async function runRemoteSessionCleanup(
dispatchResult,
...cleanupResults,
];
const failures = results.filter((result) => !result.ok && !result.skipped);
const failures = results.filter(
(result) => !result.ok && !result.skipped && result.fatal !== false
);
const bestEffortFailures = results.filter(
(result) => !result.ok && !result.skipped && result.fatal === false
);
console.info('agent session remote cleanup completed', {
roomName,
sessionId,
results,
failures,
bestEffortFailures,
});
markRoomSessionStopped(roomName, sessionId);
return { results, failures };
Expand Down
1 change: 1 addition & 0 deletions components/app/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export function App({ appConfig }: AppProps) {
<FilteredAudioRenderer
excludeTrackNames={appConfig.excludeAudioTracks}
debugAudio={appConfig.debugAudio}
observabilityEnabled={appConfig.observabilityEnabled}
/>
<AudioFilterDebug
excludeTrackNames={appConfig.excludeAudioTracks}
Expand Down
6 changes: 3 additions & 3 deletions components/app/session-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,21 @@ const DEFAULT_BROWSER_SOURCE_CLIENT: BrowserSourceClient = {
setAudioEnabled: async () => {},
setVideoEnabled: async () => {},
start: async () => {},
stop: () => {},
stop: async () => {},
};

const SessionContext = createContext<{
appConfig: AppConfig;
isSessionActive: boolean;
startSession: () => Promise<void>;
endSession: () => void;
endSession: () => Promise<void>;
getCurrentSessionId: () => string | null;
browserSourceClient: BrowserSourceClient;
}>({
appConfig: APP_CONFIG_DEFAULTS,
isSessionActive: false,
startSession: async () => {},
endSession: () => {},
endSession: async () => {},
getCurrentSessionId: () => null,
browserSourceClient: DEFAULT_BROWSER_SOURCE_CLIENT,
});
Expand Down
18 changes: 14 additions & 4 deletions components/app/tile-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { APP_CONFIG_DEFAULTS, type VideoTrackConfig } from '@/app-config';
import { useSelectedVideoTrack } from '@/hooks/useSelectedVideoTrack';
import { useSmartVoiceAssistant } from '@/hooks/useSmartVoiceAssistant';
import { cn } from '@/lib/utils';
import { resolveCameraPreviewTrack } from '@/lib/video-preview-selection';

function debugVideoLog(enabled: boolean | undefined, ...args: unknown[]) {
if (enabled) {
Expand Down Expand Up @@ -183,11 +184,20 @@ export function TileLayout({
videoTrackConfigs,
]);

const selectedTrackType = useMemo(() => {
if (!selectedTrackId) return null;
return videoTrackConfigs.find((config) => config.id === selectedTrackId)?.type ?? null;
}, [selectedTrackId, videoTrackConfigs]);

const canShowDefaultCameraPreview = showDefaultCameraPreview && !isPreviewDisabled;
const cameraTrack =
selectedTrack ||
(canShowDefaultCameraPreview && selectedTrackId === null ? configuredCameraTrack : undefined) ||
(canShowDefaultCameraPreview ? defaultCameraTrack : undefined);
const cameraTrack = resolveCameraPreviewTrack<TrackReference>({
selectedTrack,
selectedTrackId,
selectedTrackType,
canShowDefaultCameraPreview,
configuredCameraTrack,
defaultCameraTrack,
});

const isCameraEnabled = Boolean(cameraTrack?.publication && !cameraTrack.publication.isMuted);
const isScreenShareEnabled = Boolean(screenShareTrack && !screenShareTrack.publication.isMuted);
Expand Down
4 changes: 2 additions & 2 deletions components/livekit/agent-control-bar/agent-control-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,8 @@ export function AgentControlBar({

const handleDisconnect = useCallback(async () => {
const sessionId = getCurrentSessionId() ?? getActiveAgentSession()?.sessionId;
const localDisconnectPromise = Promise.resolve().then(() => {
endSession();
const localDisconnectPromise = Promise.resolve().then(async () => {
await endSession();
onDisconnect?.();
});
registerAgentSessionLocalCleanup(localDisconnectPromise);
Expand Down
Loading
Loading