mirror of
https://github.com/ValueCell-ai/ClawX.git
synced 2026-08-16 09:52:01 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfc5bca9c9 | ||
|
|
33a58f621b | ||
|
|
b60e8c6f81 | ||
|
|
6bbb050750 | ||
|
|
88cfc31fa7 | ||
|
|
bd1aac8e94 | ||
|
|
0116f6e968 |
@@ -0,0 +1,223 @@
|
||||
# ClawX Realtime Talk Design
|
||||
|
||||
Date: 2026-08-16
|
||||
|
||||
## Goal
|
||||
|
||||
Add OpenClaw realtime Talk to ClawX Chat. A user can start a duplex voice conversation from the selected Chat session. ClawX uses OpenClaw's Gateway-managed realtime relay, reuses the selected session key for Agent consults, and keeps OpenClaw as the only durable source of truth.
|
||||
|
||||
## Scope
|
||||
|
||||
- Support Gateway Relay realtime Talk only.
|
||||
- Discover the active realtime provider, models, voices, and readiness through `talk.catalog`.
|
||||
- Add a Talk start/stop control to the Chat composer.
|
||||
- Capture microphone input, play realtime provider output, show live transcripts, and support barge-in.
|
||||
- Preserve OpenClaw's default mixed behavior: the realtime model may answer directly or call `openclaw_agent_consult`.
|
||||
- Add basic Settings > Talk controls for provider, model, speaker voice, and readiness.
|
||||
- Officially validate on macOS. Windows and Linux use the same Chromium implementation and receive automated coverage, but do not block the first release.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Client-owned WebRTC or provider WebSocket transports.
|
||||
- Camera/video Talk.
|
||||
- Push-to-talk dictation or audio-to-composer input.
|
||||
- Audio recording or saving raw audio.
|
||||
- A ClawX-owned database, transcript ledger, sidecar JSON history, or any other durable Talk storage.
|
||||
- Writing OpenClaw session transcript files directly or modifying the OpenClaw repository.
|
||||
- Forcing all utterances through Agent consult.
|
||||
|
||||
## Session Ownership
|
||||
|
||||
The persistent user-visible session is the selected OpenClaw `sessionKey`. ACP loads and renders its text, tool, permission, and history semantics, but does not own the underlying session.
|
||||
|
||||
Each active call has a temporary Gateway `relaySessionId`:
|
||||
|
||||
```text
|
||||
OpenClaw sessionKey
|
||||
|- ACP text session and timeline
|
||||
`- active relaySessionId for one realtime call
|
||||
```
|
||||
|
||||
- Talk always receives the currently selected, non-heartbeat-only `sessionKey`.
|
||||
- Talk must never fall back to `DEFAULT_SESSION_KEY`.
|
||||
- If no normal session exists, ClawX first uses the existing ACP load/create path to materialize one, then creates the relay.
|
||||
- A relay session expires or is closed independently of the text session.
|
||||
- Only one Talk relay can be active in ClawX at a time.
|
||||
- While Talk is active for a session, ClawX locks the text composer for that session. It restores the draft after Talk stops, fails, or the Gateway disconnects.
|
||||
|
||||
## History Semantics
|
||||
|
||||
OpenClaw's realtime relay emits direct provider transcripts through `talk.event`, but does not expose an API that persists those transcript entries as ordinary session messages. ClawX must not create a substitute persistence layer.
|
||||
|
||||
### Direct Realtime Answers
|
||||
|
||||
When the realtime model answers without an Agent consult:
|
||||
|
||||
- Renderer shows partial and final user/assistant transcripts in an ephemeral Live Talk area at the end of Chat.
|
||||
- The area uses normal chat-bubble styling and shows a microphone source marker.
|
||||
- These entries are in-memory UI state only.
|
||||
- They are removed after Talk ends, session changes, a Chat reload, or application restart.
|
||||
- They do not appear in ACP history or session replay.
|
||||
|
||||
### Agent Consult Answers
|
||||
|
||||
When the realtime model calls `openclaw_agent_consult`:
|
||||
|
||||
1. ClawX invokes `talk.client.toolCall` for the active session key and relay session.
|
||||
2. OpenClaw invokes its normal `chat.send` path for that session key.
|
||||
3. OpenClaw persists the normal user and assistant turn in its own session transcript.
|
||||
4. ClawX returns the completed result using `talk.session.submitToolResult`.
|
||||
5. The realtime model speaks an answer based on that result.
|
||||
6. After the consult completes, ClawX reloads the current ACP session. ACP is the only source that renders the durable user and assistant chat bubbles.
|
||||
|
||||
The spoken realtime response may be phrased differently from the persisted Agent response. Persistent ACP bubbles do not require a microphone marker because OpenClaw does not persist a stable voice-source annotation for these turns.
|
||||
|
||||
## User Interface
|
||||
|
||||
### Chat
|
||||
|
||||
- Add a microphone button beside the Chat send controls.
|
||||
- Before starting, validate Gateway connection, Talk readiness, selected session eligibility, and no active ACP prompt.
|
||||
- When active, replace the microphone control with live level feedback, localized status, and a stop action.
|
||||
- Disable message send, model selection, attachment selection, agent targeting, and text editing while Talk is active.
|
||||
- Display an ephemeral Live Talk area immediately above the composer. It presents ordered user and assistant partial/final transcripts.
|
||||
- Use localized status states: connecting, listening, thinking, speaking, disconnected, and error.
|
||||
- For provider direct transcripts, render the microphone marker only within Live Talk.
|
||||
- On failure, retain the transient Live Talk area until the user dismisses the error or exits Talk; never convert it into durable chat history.
|
||||
- Stop Talk when the user switches sessions, the Chat page unmounts, the window closes, or the Gateway connection is lost.
|
||||
|
||||
### Settings
|
||||
|
||||
Add a Talk section in Settings with:
|
||||
|
||||
- Realtime provider picker.
|
||||
- Realtime model picker.
|
||||
- Speaker voice picker.
|
||||
- Gateway readiness and unavailable-reason display.
|
||||
- Link to Advanced Developer configuration for API keys, VAD settings, and provider-specific raw configuration.
|
||||
|
||||
Picker options come exclusively from `talk.catalog`. Writes update only:
|
||||
|
||||
- `talk.realtime.provider`
|
||||
- `talk.realtime.model`
|
||||
- `talk.realtime.speakerVoice`
|
||||
|
||||
The first release fixes runtime transport to `gateway-relay`; Settings does not expose transport selection.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Typed Host Boundary
|
||||
|
||||
Renderer does not call Gateway RPCs or Gateway HTTP endpoints directly. Add a dedicated `hostApi.talk` module backed by Electron Main:
|
||||
|
||||
```ts
|
||||
hostApi.talk.catalog()
|
||||
hostApi.talk.startRelay({ sessionKey })
|
||||
hostApi.talk.appendAudio({ relaySessionId, audioBase64, timestamp })
|
||||
hostApi.talk.cancelOutput({ relaySessionId })
|
||||
hostApi.talk.submitToolResult({ relaySessionId, callId, result })
|
||||
hostApi.talk.acknowledgeMark({ relaySessionId, markName })
|
||||
hostApi.talk.stopRelay({ relaySessionId })
|
||||
```
|
||||
|
||||
Add a typed `hostEvents.onTalkEvent` subscription. Electron Main owns event routing and only emits an event to the active renderer controller when its `relaySessionId` matches the active relay.
|
||||
|
||||
The generic `hostApi.gateway.rpc` remains an internal compatibility surface and is not used by Talk Renderer code.
|
||||
|
||||
### Gateway Protocol
|
||||
|
||||
Start a relay with:
|
||||
|
||||
```ts
|
||||
talk.session.create({
|
||||
sessionKey,
|
||||
mode: 'realtime',
|
||||
transport: 'gateway-relay',
|
||||
brain: 'agent-consult',
|
||||
})
|
||||
```
|
||||
|
||||
The result supplies the relay session identifier and audio contract. The first release accepts the Gateway Relay PCM16 contract. Renderer sends base64 PCM16 frames through `talk.session.appendAudio` and receives provider events through `talk.event`.
|
||||
|
||||
The Main-process event dispatcher recognizes `talk.event` as a dedicated protocol event instead of treating it as a generic Gateway notification.
|
||||
|
||||
### Audio Pipeline
|
||||
|
||||
```text
|
||||
Renderer microphone
|
||||
-> getUserMedia
|
||||
-> AudioWorklet PCM frames
|
||||
-> hostApi.talk.appendAudio
|
||||
-> Main GatewayManager RPC
|
||||
-> OpenClaw Gateway relay
|
||||
-> realtime provider
|
||||
|
||||
realtime provider output
|
||||
-> Gateway talk.event
|
||||
-> Main typed Talk event
|
||||
-> Renderer Web Audio PCM queue
|
||||
-> speakers
|
||||
```
|
||||
|
||||
- Renderer uses `getUserMedia` for microphone access and `AudioWorklet` for sample handling.
|
||||
- Input requests use a bounded in-flight queue. If Gateway cannot keep up, discard stale microphone frames rather than accumulating latency.
|
||||
- Renderer decodes returned PCM16 into a bounded Web Audio playback queue.
|
||||
- While playback is active, detected user speech invokes `talk.session.cancelOutput` to support barge-in.
|
||||
- Renderer sends mark acknowledgements only after the corresponding queued output reaches playback completion.
|
||||
|
||||
### Agent Consult Bridge
|
||||
|
||||
Port the Gateway Relay client behavior from OpenClaw Control UI rather than defining a new Agent protocol.
|
||||
|
||||
- For `openclaw_agent_consult`, invoke `talk.client.toolCall` with the selected session key, call id, arguments, and relay session id.
|
||||
- Submit progress and terminal results through `talk.session.submitToolResult`.
|
||||
- Handle provider cancellation, Gateway run cancellation, duplicate call ids, and late results idempotently.
|
||||
- Handle OpenClaw's realtime Agent control tool with the same Gateway Relay protocol behavior.
|
||||
- ACP is never used to issue a Talk consult. ACP is reloaded only to display the OpenClaw-owned transcript produced by the consult's normal `chat.send` turn.
|
||||
|
||||
## Failure and Lifecycle Handling
|
||||
|
||||
- Missing or unready Talk provider: do not create a relay; show a localized error and Settings link.
|
||||
- Microphone permission denial, no input device, AudioWorklet failure, or unsupported audio contract: stop local resources and show a localized error.
|
||||
- Gateway RPC failure, provider error, playback queue overflow, or relay close: stop the relay, release microphone and audio contexts, restore the composer, and retain transient content only until user dismissal or Talk exit.
|
||||
- Gateway reconnect does not replay microphone frames or resurrect an old relay. It ends the active Talk session with a reconnect error.
|
||||
- Session switch, component unmount, application shutdown, and window close close the relay best-effort and immediately release local audio resources.
|
||||
- An ACP reload failure after a successful consult does not create an alternate transcript projection. It exposes a retryable synchronization message and leaves OpenClaw history authoritative.
|
||||
|
||||
## Localization and Styling
|
||||
|
||||
- All Talk user-facing text is localized in `en`, `zh`, `ja`, and `ru` locale files.
|
||||
- New UI follows the repository token rules in `src/styles/globals.css`.
|
||||
- Status and error colors use existing semantic tokens.
|
||||
- Talk controls are keyboard-accessible and expose non-audio status updates through appropriate live regions.
|
||||
|
||||
## Validation
|
||||
|
||||
### Unit Tests
|
||||
|
||||
- Talk lifecycle state transitions and relay ownership.
|
||||
- Session eligibility and composer lock behavior.
|
||||
- Gateway event filtering by relay session id.
|
||||
- PCM conversion, input backpressure, playback bounds, and barge-in requests.
|
||||
- Consult tool-call sequencing, cancellation, idempotency, and ACP reload trigger.
|
||||
- Catalog parsing, Settings mapping, and readiness failures.
|
||||
- No durable ClawX Talk transcript store is introduced.
|
||||
|
||||
### Electron E2E Tests
|
||||
|
||||
- Start button readiness and unavailable state.
|
||||
- Active Talk locks the text composer and restores its draft after stop.
|
||||
- Live direct transcript bubbles appear with microphone source markers.
|
||||
- Permission denial and Gateway disconnect cleanly end Talk.
|
||||
- Consult completion causes ACP-native messages to appear after reload.
|
||||
- Direct realtime transcript bubbles do not survive Chat reload.
|
||||
|
||||
### Manual macOS Acceptance
|
||||
|
||||
- Validate microphone permission, input level, provider output, barge-in, direct provider reply, Agent consult reply, stop, and Gateway reconnect against an installed configured provider.
|
||||
|
||||
## Documentation and Harness
|
||||
|
||||
- Update `README.md`, `README.zh-CN.md`, and `README.ja-JP.md` to describe Talk availability and its Gateway Relay architecture.
|
||||
- Add a Talk scenario, rule, and task specification under `harness/specs/`.
|
||||
- The task specification references `gateway-backend-communication` and is validated before implementation.
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
GatewayChatMessageEvent,
|
||||
GatewayRuntimePayload,
|
||||
} from '@shared/host-events/contract';
|
||||
import type { TalkRelayEvent } from '@shared/talk/types';
|
||||
|
||||
type GatewayEventEmitter = {
|
||||
emit: (event: string, payload: unknown) => boolean;
|
||||
@@ -43,6 +44,9 @@ export function dispatchProtocolEvent(
|
||||
case 'presence':
|
||||
emitter.emit('gateway:presence', payload as GatewayRuntimePayload);
|
||||
break;
|
||||
case 'talk.event':
|
||||
emitter.emit('talk:event', payload as TalkRelayEvent);
|
||||
break;
|
||||
default:
|
||||
emitter.emit('notification', { method: event, params: payload });
|
||||
}
|
||||
@@ -57,6 +61,8 @@ export function dispatchJsonRpcNotification(
|
||||
if (normalized) {
|
||||
emitter.emit('chat:runtime-event', normalized);
|
||||
}
|
||||
} else if (notification.method === 'talk.event') {
|
||||
emitter.emit('talk:event', notification.params as TalkRelayEvent);
|
||||
} else {
|
||||
emitter.emit('notification', notification);
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ import type {
|
||||
GatewayRuntimePayload,
|
||||
} from '@shared/host-events/contract';
|
||||
import type { ChatRuntimeEvent } from '@shared/chat-runtime-events';
|
||||
import type { TalkRelayEvent } from '@shared/talk/types';
|
||||
|
||||
export interface GatewayStatus {
|
||||
state: GatewayLifecycleState;
|
||||
@@ -162,6 +163,7 @@ export interface GatewayManagerEvents {
|
||||
'channel:status': (data: GatewayChannelStatusEvent) => void;
|
||||
'chat:message': (data: GatewayChatMessageEvent) => void;
|
||||
'chat:runtime-event': (data: ChatRuntimeEvent) => void;
|
||||
'talk:event': (data: TalkRelayEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -54,6 +54,7 @@ import { deviceOAuthManager } from '../utils/device-oauth';
|
||||
import { browserOAuthManager } from '../utils/browser-oauth';
|
||||
import { whatsAppLoginManager } from '../utils/whatsapp-login';
|
||||
import { syncAllProviderAuthToRuntime } from '../services/providers/provider-runtime-sync';
|
||||
import { forwardActiveTalkEvent } from '../services/talk-api';
|
||||
|
||||
const WINDOWS_APP_USER_MODEL_ID = 'app.clawx.desktop';
|
||||
const isE2EMode = process.env.CLAWX_E2E === '1';
|
||||
@@ -355,7 +356,7 @@ async function initialize(): Promise<void> {
|
||||
);
|
||||
|
||||
// Register IPC handlers
|
||||
registerIpcHandlers(
|
||||
const talkRelayOwnership = registerIpcHandlers(
|
||||
gatewayManager,
|
||||
clawHubService,
|
||||
window,
|
||||
@@ -483,6 +484,12 @@ async function initialize(): Promise<void> {
|
||||
sendMainWindowEvent('chat:runtime-event', data);
|
||||
});
|
||||
|
||||
gatewayManager.on('talk:event', (data) => {
|
||||
forwardActiveTalkEvent(talkRelayOwnership, data, (event) => {
|
||||
sendMainWindowEvent('talk:event', event);
|
||||
});
|
||||
});
|
||||
|
||||
gatewayManager.on('channel:status', (data) => {
|
||||
sendMainWindowEvent('gateway:channel-status', data);
|
||||
});
|
||||
|
||||
@@ -65,6 +65,7 @@ import { createSessionsApi } from '../services/sessions-api';
|
||||
import { createSkillsApi } from '../services/skills-api';
|
||||
import { createUsageApi } from '../services/usage-api';
|
||||
import { createWebBrowserApi } from '../services/web-browser-api';
|
||||
import { createTalkApi, createTalkRelayOwnership, type TalkRelayOwnership } from '../services/talk-api';
|
||||
import type { WebBrowserGuestRegistry } from './web-browser-policy';
|
||||
import {
|
||||
isLaunchAtStartupKey,
|
||||
@@ -85,12 +86,12 @@ export function registerIpcHandlers(
|
||||
hostApiRegistry: HostApiRegistry,
|
||||
browserSession: Session,
|
||||
registry: WebBrowserGuestRegistry,
|
||||
): void {
|
||||
): TalkRelayOwnership {
|
||||
// Unified request protocol (non-breaking: legacy channels remain available)
|
||||
registerUnifiedRequestHandlers(gatewayManager);
|
||||
|
||||
// Typed host invoke handlers (new renderer facade; legacy channels remain available)
|
||||
registerTypedHostHandlers(
|
||||
const talkRelayOwnership = registerTypedHostHandlers(
|
||||
gatewayManager,
|
||||
clawHubService,
|
||||
mainWindow,
|
||||
@@ -134,6 +135,8 @@ export function registerIpcHandlers(
|
||||
|
||||
// File preview handlers (sandboxed read/write/list for inline viewer)
|
||||
registerFilePreviewHandlers();
|
||||
|
||||
return talkRelayOwnership;
|
||||
}
|
||||
|
||||
function registerTypedHostHandlers(
|
||||
@@ -143,7 +146,7 @@ function registerTypedHostHandlers(
|
||||
hostApiRegistry: HostApiRegistry,
|
||||
browserSession: Session,
|
||||
registry: WebBrowserGuestRegistry,
|
||||
): void {
|
||||
): TalkRelayOwnership {
|
||||
const acpSessionAccessRegistry = new AcpSessionAccessRegistry();
|
||||
const stagedAttachments = new StagedAttachmentRegistry();
|
||||
const attachmentOpenWith = createAttachmentOpenWithService();
|
||||
@@ -152,6 +155,7 @@ function registerTypedHostHandlers(
|
||||
stagedAttachments,
|
||||
openWith: attachmentOpenWith,
|
||||
});
|
||||
const talkRelayOwnership = createTalkRelayOwnership();
|
||||
hostApiRegistry.registerCoreServices({
|
||||
app: createAppApi(),
|
||||
openclaw: createOpenClawApi(),
|
||||
@@ -163,6 +167,7 @@ function registerTypedHostHandlers(
|
||||
uv: createUvApi(),
|
||||
settings: createSettingsApi(gatewayManager),
|
||||
gateway: createGatewayApi(gatewayManager),
|
||||
talk: createTalkApi(gatewayManager, talkRelayOwnership),
|
||||
logs: createLogsApi(),
|
||||
channels: createChannelsApi({ gatewayManager, mainWindow }),
|
||||
agents: createAgentsApi({ gatewayManager }),
|
||||
@@ -180,6 +185,7 @@ function registerTypedHostHandlers(
|
||||
usage: createUsageApi(),
|
||||
});
|
||||
registerHostInvokeHandler(hostApiRegistry);
|
||||
return talkRelayOwnership;
|
||||
}
|
||||
|
||||
function registerUnifiedRequestHandlers(gatewayManager: GatewayManager): void {
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
/**
|
||||
* Window Management Utilities
|
||||
* Handles window state persistence and multi-window management
|
||||
*/
|
||||
import { BrowserWindow, screen } from 'electron';
|
||||
|
||||
interface WindowState {
|
||||
x?: number;
|
||||
y?: number;
|
||||
width: number;
|
||||
height: number;
|
||||
isMaximized: boolean;
|
||||
}
|
||||
|
||||
// Lazy-load electron-store (ESM module)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let windowStateStore: any = null;
|
||||
|
||||
async function getStore() {
|
||||
if (!windowStateStore) {
|
||||
const Store = (await import('electron-store')).default;
|
||||
windowStateStore = new Store<{ windowState: WindowState }>({
|
||||
name: 'window-state',
|
||||
defaults: {
|
||||
windowState: {
|
||||
width: 1280,
|
||||
height: 800,
|
||||
isMaximized: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return windowStateStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get saved window state with bounds validation
|
||||
*/
|
||||
export async function getWindowState(): Promise<WindowState> {
|
||||
const store = await getStore();
|
||||
const state = store.get('windowState');
|
||||
|
||||
// Validate that the window is visible on a screen
|
||||
if (state.x !== undefined && state.y !== undefined) {
|
||||
const displays = screen.getAllDisplays();
|
||||
const isVisible = displays.some((display) => {
|
||||
const { x, y, width, height } = display.bounds;
|
||||
return (
|
||||
state.x! >= x &&
|
||||
state.x! < x + width &&
|
||||
state.y! >= y &&
|
||||
state.y! < y + height
|
||||
);
|
||||
});
|
||||
|
||||
if (!isVisible) {
|
||||
// Reset position if not visible
|
||||
delete state.x;
|
||||
delete state.y;
|
||||
}
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save window state
|
||||
*/
|
||||
export async function saveWindowState(win: BrowserWindow): Promise<void> {
|
||||
const store = await getStore();
|
||||
const isMaximized = win.isMaximized();
|
||||
|
||||
if (!isMaximized) {
|
||||
const bounds = win.getBounds();
|
||||
store.set('windowState', {
|
||||
x: bounds.x,
|
||||
y: bounds.y,
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
isMaximized,
|
||||
});
|
||||
} else {
|
||||
store.set('windowState.isMaximized', true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track window state changes
|
||||
*/
|
||||
export function trackWindowState(win: BrowserWindow): void {
|
||||
// Save state on window events
|
||||
['resize', 'move', 'close'].forEach((event) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
win.on(event as any, () => saveWindowState(win));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import type { GatewayManager } from '../gateway/manager';
|
||||
import type { CompleteHostServiceRegistry } from '../main/ipc/host-contract';
|
||||
import type { TalkRelayEvent } from '@shared/talk/types';
|
||||
import { isRecord } from './payload-utils';
|
||||
|
||||
export const TALK_RPC_TIMEOUT_MS = 8_000;
|
||||
|
||||
export type TalkRelayOwnership = {
|
||||
activate: (relaySessionId: string) => void;
|
||||
clear: (relaySessionId?: string) => void;
|
||||
isActive: (relaySessionId: string) => boolean;
|
||||
};
|
||||
|
||||
export function createTalkRelayOwnership(): TalkRelayOwnership {
|
||||
let activeRelaySessionId: string | undefined;
|
||||
return {
|
||||
activate: (relaySessionId) => {
|
||||
activeRelaySessionId = relaySessionId;
|
||||
},
|
||||
clear: (relaySessionId) => {
|
||||
if (relaySessionId === undefined || activeRelaySessionId === relaySessionId) {
|
||||
activeRelaySessionId = undefined;
|
||||
}
|
||||
},
|
||||
isActive: (relaySessionId) => activeRelaySessionId === relaySessionId,
|
||||
};
|
||||
}
|
||||
|
||||
export function forwardActiveTalkEvent(
|
||||
ownership: TalkRelayOwnership,
|
||||
event: TalkRelayEvent,
|
||||
sendToRenderer: (event: TalkRelayEvent) => void,
|
||||
): void {
|
||||
if (!ownership.isActive(event.relaySessionId)) return;
|
||||
if (event.type === 'close') {
|
||||
ownership.clear(event.relaySessionId);
|
||||
}
|
||||
sendToRenderer(event);
|
||||
}
|
||||
|
||||
function requiredString(value: unknown, message: string): string {
|
||||
if (typeof value !== 'string' || !value.trim()) {
|
||||
throw new Error(message);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function parseRelayId(value: unknown): string {
|
||||
return requiredString(value, 'Invalid Talk relay session id');
|
||||
}
|
||||
|
||||
function parsePcm16Base64(value: unknown): string {
|
||||
if (
|
||||
typeof value !== 'string'
|
||||
|| !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)
|
||||
) {
|
||||
throw new Error('Invalid Talk PCM16 audio');
|
||||
}
|
||||
const bytes = Buffer.from(value, 'base64');
|
||||
if (bytes.length === 0 || bytes.length % 2 !== 0) {
|
||||
throw new Error('Invalid Talk PCM16 audio');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseTimestamp(value: unknown): number | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
|
||||
throw new Error('Invalid Talk audio timestamp');
|
||||
}
|
||||
return Math.floor(value);
|
||||
}
|
||||
|
||||
function parseToolResultOptions(value: unknown): { suppressResponse?: boolean; willContinue?: boolean } | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (!isRecord(value)
|
||||
|| (value.suppressResponse !== undefined && typeof value.suppressResponse !== 'boolean')
|
||||
|| (value.willContinue !== undefined && typeof value.willContinue !== 'boolean')) {
|
||||
throw new Error('Invalid Talk tool result options');
|
||||
}
|
||||
return {
|
||||
...(typeof value.suppressResponse === 'boolean' ? { suppressResponse: value.suppressResponse } : {}),
|
||||
...(typeof value.willContinue === 'boolean' ? { willContinue: value.willContinue } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function createTalkApi(
|
||||
gatewayManager: GatewayManager,
|
||||
ownership: TalkRelayOwnership = createTalkRelayOwnership(),
|
||||
): CompleteHostServiceRegistry['talk'] {
|
||||
let ownershipGeneration = 0;
|
||||
return {
|
||||
catalog: async () => await gatewayManager.rpc('talk.catalog', {}, TALK_RPC_TIMEOUT_MS),
|
||||
startRelay: async (payload) => {
|
||||
const body: Record<string, unknown> = isRecord(payload) ? payload : {};
|
||||
const sessionKey = requiredString(body.sessionKey, 'Invalid Talk session key');
|
||||
const generation = ++ownershipGeneration;
|
||||
ownership.clear();
|
||||
const relay = await gatewayManager.rpc('talk.session.create', {
|
||||
sessionKey,
|
||||
mode: 'realtime',
|
||||
transport: 'gateway-relay',
|
||||
brain: 'agent-consult',
|
||||
}, TALK_RPC_TIMEOUT_MS);
|
||||
if (!isRecord(relay)) {
|
||||
throw new Error('Invalid Talk relay session');
|
||||
}
|
||||
const relaySessionId = parseRelayId(relay.relaySessionId);
|
||||
if (generation !== ownershipGeneration) {
|
||||
void gatewayManager.rpc('talk.session.close', { sessionId: relaySessionId }, TALK_RPC_TIMEOUT_MS)
|
||||
.catch(() => undefined);
|
||||
return relay as never;
|
||||
}
|
||||
ownership.activate(relaySessionId);
|
||||
return relay as never;
|
||||
},
|
||||
appendAudio: async (payload) => {
|
||||
const body: Record<string, unknown> = isRecord(payload) ? payload : {};
|
||||
const timestamp = parseTimestamp(body.timestamp);
|
||||
return await gatewayManager.rpc('talk.session.appendAudio', {
|
||||
sessionId: parseRelayId(body.relaySessionId),
|
||||
audioBase64: parsePcm16Base64(body.audioBase64),
|
||||
...(timestamp === undefined ? {} : { timestamp }),
|
||||
}, TALK_RPC_TIMEOUT_MS);
|
||||
},
|
||||
cancelOutput: async (payload) => {
|
||||
const body: Record<string, unknown> = isRecord(payload) ? payload : {};
|
||||
return await gatewayManager.rpc('talk.session.cancelOutput', {
|
||||
sessionId: parseRelayId(body.relaySessionId),
|
||||
}, TALK_RPC_TIMEOUT_MS);
|
||||
},
|
||||
submitToolResult: async (payload) => {
|
||||
const body: Record<string, unknown> = isRecord(payload) ? payload : {};
|
||||
if (!('result' in body)) {
|
||||
throw new Error('Invalid Talk tool result');
|
||||
}
|
||||
const options = parseToolResultOptions(body.options);
|
||||
return await gatewayManager.rpc('talk.session.submitToolResult', {
|
||||
sessionId: parseRelayId(body.relaySessionId),
|
||||
callId: requiredString(body.callId, 'Invalid Talk tool call id'),
|
||||
result: body.result,
|
||||
...(options && Object.keys(options).length > 0 ? { options } : {}),
|
||||
}, TALK_RPC_TIMEOUT_MS);
|
||||
},
|
||||
acknowledgeMark: async (payload) => {
|
||||
const body: Record<string, unknown> = isRecord(payload) ? payload : {};
|
||||
return await gatewayManager.rpc('talk.session.acknowledgeMark', {
|
||||
sessionId: parseRelayId(body.relaySessionId),
|
||||
markName: requiredString(body.markName, 'Invalid Talk mark name'),
|
||||
}, TALK_RPC_TIMEOUT_MS);
|
||||
},
|
||||
stopRelay: async (payload) => {
|
||||
const body: Record<string, unknown> = isRecord(payload) ? payload : {};
|
||||
const relaySessionId = parseRelayId(body.relaySessionId);
|
||||
ownershipGeneration += 1;
|
||||
ownership.clear(relaySessionId);
|
||||
return await gatewayManager.rpc('talk.session.close', {
|
||||
sessionId: relaySessionId,
|
||||
}, TALK_RPC_TIMEOUT_MS);
|
||||
},
|
||||
startAgentConsult: async (payload) => {
|
||||
const body: Record<string, unknown> = isRecord(payload) ? payload : {};
|
||||
if (!isRecord(body.args)) {
|
||||
throw new Error('Invalid Talk tool arguments');
|
||||
}
|
||||
return await gatewayManager.rpc('talk.client.toolCall', {
|
||||
relaySessionId: parseRelayId(body.relaySessionId),
|
||||
sessionKey: requiredString(body.sessionKey, 'Invalid Talk session key'),
|
||||
callId: requiredString(body.callId, 'Invalid Talk tool call id'),
|
||||
name: 'openclaw_agent_consult',
|
||||
args: body.args,
|
||||
}, TALK_RPC_TIMEOUT_MS);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -95,6 +95,7 @@
|
||||
"electron-updater": "^6.8.3",
|
||||
"json5": "2.2.3",
|
||||
"node-machine-id": "^1.1.12",
|
||||
"pino": "^9.14.0",
|
||||
"posthog-node": "^5.28.0",
|
||||
"tar": "^6.2.1",
|
||||
"timeago.js": "^4.0.2",
|
||||
|
||||
Generated
+7
-4
@@ -31,6 +31,9 @@ importers:
|
||||
node-machine-id:
|
||||
specifier: ^1.1.12
|
||||
version: 1.1.12
|
||||
pino:
|
||||
specifier: ^9.14.0
|
||||
version: 9.14.0
|
||||
posthog-node:
|
||||
specifier: ^5.28.0
|
||||
version: 5.28.5
|
||||
@@ -4677,8 +4680,8 @@ packages:
|
||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
libsignal@git+https://git@github.com:whiskeysockets/libsignal-node.git#bcea72df9ec34d9d9140ab30619cf479c7c144c7:
|
||||
resolution: {commit: bcea72df9ec34d9d9140ab30619cf479c7c144c7, repo: git@github.com:whiskeysockets/libsignal-node.git, type: git}
|
||||
libsignal@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/bcea72df9ec34d9d9140ab30619cf479c7c144c7:
|
||||
resolution: {gitHosted: true, tarball: https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/bcea72df9ec34d9d9140ab30619cf479c7c144c7}
|
||||
version: 6.0.0
|
||||
|
||||
lie@3.3.0:
|
||||
@@ -9571,7 +9574,7 @@ snapshots:
|
||||
'@cacheable/node-cache': 1.7.6
|
||||
'@hapi/boom': 9.1.4
|
||||
async-mutex: 0.5.0
|
||||
libsignal: git+https://git@github.com:whiskeysockets/libsignal-node.git#bcea72df9ec34d9d9140ab30619cf479c7c144c7
|
||||
libsignal: https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/bcea72df9ec34d9d9140ab30619cf479c7c144c7
|
||||
lru-cache: 11.2.7
|
||||
music-metadata: 11.12.3
|
||||
p-queue: 9.1.0
|
||||
@@ -11829,7 +11832,7 @@ snapshots:
|
||||
prelude-ls: 1.2.1
|
||||
type-check: 0.4.0
|
||||
|
||||
libsignal@git+https://git@github.com:whiskeysockets/libsignal-node.git#bcea72df9ec34d9d9140ab30619cf479c7c144c7:
|
||||
libsignal@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/bcea72df9ec34d9d9140ab30619cf479c7c144c7:
|
||||
dependencies:
|
||||
curve25519-js: 0.0.4
|
||||
protobufjs: 7.5.8
|
||||
|
||||
@@ -11,6 +11,18 @@ import type { CronJob, CronJobCreateInput, CronJobUpdateInput } from '../types/c
|
||||
import type { GatewayHealth, GatewayStatus } from '../types/gateway';
|
||||
import type { MarketplaceSkill, QuickAccessSkill, Skill } from '../types/skill';
|
||||
import type { WebBrowserNavigatePayload } from '../web-browser';
|
||||
import type {
|
||||
TalkAcknowledgeMarkPayload,
|
||||
TalkAgentConsultResult,
|
||||
TalkAppendAudioPayload,
|
||||
TalkCatalog,
|
||||
TalkOperationResult,
|
||||
TalkRelayIdPayload,
|
||||
TalkRelaySession,
|
||||
TalkStartAgentConsultPayload,
|
||||
TalkStartRelayPayload,
|
||||
TalkSubmitToolResultPayload,
|
||||
} from '../talk/types';
|
||||
|
||||
export type JsonRecord = Record<string, unknown>;
|
||||
export type HostSuccess = { success: boolean; error?: string };
|
||||
@@ -865,6 +877,16 @@ export type HostApiContract = {
|
||||
controlUi: () => GatewayControlUiResult;
|
||||
rpc: (payload: GatewayRpcPayload) => unknown;
|
||||
};
|
||||
talk: {
|
||||
catalog: () => TalkCatalog;
|
||||
startRelay: (payload: TalkStartRelayPayload) => TalkRelaySession;
|
||||
appendAudio: (payload: TalkAppendAudioPayload) => TalkOperationResult;
|
||||
cancelOutput: (payload: TalkRelayIdPayload) => TalkOperationResult;
|
||||
submitToolResult: (payload: TalkSubmitToolResultPayload) => TalkOperationResult;
|
||||
acknowledgeMark: (payload: TalkAcknowledgeMarkPayload) => TalkOperationResult;
|
||||
stopRelay: (payload: TalkRelayIdPayload) => TalkOperationResult;
|
||||
startAgentConsult: (payload: TalkStartAgentConsultPayload) => TalkAgentConsultResult;
|
||||
};
|
||||
logs: {
|
||||
recent: (payload?: LogRecentPayload) => LogContentResult;
|
||||
memory: (payload?: LogMemoryPayload) => string[];
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
} from '../acp-chat/types';
|
||||
import type { UpdateStatusSnapshot } from '../host-api/contract';
|
||||
import type { ChatRuntimeEvent } from '../chat-runtime-events';
|
||||
import type { TalkRelayEvent } from '../talk/types';
|
||||
import type {
|
||||
GatewayNotification,
|
||||
GatewayRuntimePayload,
|
||||
@@ -82,6 +83,9 @@ export type HostEventContract = {
|
||||
acpSessionUpdate: (payload: AcpSessionUpdateEnvelope) => void;
|
||||
acpPermissionRequest: (payload: AcpPermissionRequestEnvelope) => void;
|
||||
};
|
||||
talk: {
|
||||
event: (payload: TalkRelayEvent) => void;
|
||||
};
|
||||
oauth: {
|
||||
code: (payload: OAuthCodeEvent) => void;
|
||||
success: (payload: OAuthSuccessEvent) => void;
|
||||
@@ -131,6 +135,9 @@ export const HOST_EVENT_CHANNELS = {
|
||||
acpSessionUpdate: 'chat:acp-session-update',
|
||||
acpPermissionRequest: 'chat:acp-permission-request',
|
||||
},
|
||||
talk: {
|
||||
event: 'talk:event',
|
||||
},
|
||||
oauth: {
|
||||
code: 'oauth:code',
|
||||
success: 'oauth:success',
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
export type TalkTerminalReason = 'completed' | 'cancelled' | 'error' | 'disconnected' | (string & {});
|
||||
|
||||
export type TalkCatalogReadiness =
|
||||
| { ready: true }
|
||||
| { ready: false; reason?: string }
|
||||
| { ready?: undefined };
|
||||
|
||||
export type TalkCatalogProvider = {
|
||||
id: string;
|
||||
label: string;
|
||||
configured: boolean;
|
||||
models?: string[];
|
||||
voices?: string[];
|
||||
transports?: string[];
|
||||
brains?: string[];
|
||||
};
|
||||
|
||||
export type TalkCatalog = {
|
||||
modes: string[];
|
||||
transports: string[];
|
||||
brains: string[];
|
||||
realtime: TalkCatalogReadiness & {
|
||||
activeProvider?: string;
|
||||
providers: TalkCatalogProvider[];
|
||||
};
|
||||
};
|
||||
|
||||
export type TalkPcm16AudioContract = {
|
||||
inputEncoding: 'pcm16';
|
||||
inputSampleRateHz: number;
|
||||
outputEncoding: 'pcm16';
|
||||
outputSampleRateHz: number;
|
||||
};
|
||||
|
||||
export type TalkRelaySession = {
|
||||
relaySessionId: string;
|
||||
provider: string;
|
||||
transport: 'gateway-relay';
|
||||
audio: TalkPcm16AudioContract;
|
||||
model?: string;
|
||||
voice?: string;
|
||||
expiresAt?: number;
|
||||
};
|
||||
|
||||
export type TalkRelayEventBase = {
|
||||
relaySessionId: string;
|
||||
talkEvent?: unknown;
|
||||
};
|
||||
|
||||
export type TalkRelayEvent = TalkRelayEventBase & (
|
||||
| { type: 'ready' }
|
||||
| { type: 'audio'; audioBase64: string }
|
||||
| { type: 'clear'; reason?: 'barge-in' }
|
||||
| { type: 'mark'; markName: string }
|
||||
| { type: 'transcript'; role: 'user' | 'assistant'; text: string; final: boolean }
|
||||
| { type: 'toolCall'; callId: string; name: string; args: Record<string, unknown>; forced?: boolean }
|
||||
| { type: 'toolCallCancelled'; callId: string }
|
||||
| { type: 'toolResult'; callId: string; final?: boolean }
|
||||
| { type: 'error'; message: string }
|
||||
| { type: 'close'; reason: TalkTerminalReason }
|
||||
);
|
||||
|
||||
export type TalkStartRelayPayload = { sessionKey: string };
|
||||
export type TalkRelayIdPayload = { relaySessionId: string };
|
||||
export type TalkAppendAudioPayload = TalkRelayIdPayload & {
|
||||
audioBase64: string;
|
||||
timestamp?: number;
|
||||
};
|
||||
export type TalkSubmitToolResultPayload = TalkRelayIdPayload & {
|
||||
callId: string;
|
||||
result: unknown;
|
||||
options?: { suppressResponse?: boolean; willContinue?: boolean };
|
||||
};
|
||||
export type TalkAcknowledgeMarkPayload = TalkRelayIdPayload & { markName: string };
|
||||
export type TalkStartAgentConsultPayload = TalkRelayIdPayload & {
|
||||
sessionKey: string;
|
||||
callId: string;
|
||||
args: Record<string, unknown>;
|
||||
};
|
||||
export type TalkOperationResult = { ok: true };
|
||||
export type TalkAgentConsultResult = { runId: string; idempotencyKey: string };
|
||||
@@ -1,74 +0,0 @@
|
||||
/**
|
||||
* Error Boundary Component
|
||||
* Catches and displays errors in the component tree
|
||||
*/
|
||||
import { Component, ReactNode } from 'react';
|
||||
import { AlertTriangle, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
||||
console.error('Error caught by boundary:', error, errorInfo);
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-6">
|
||||
<Card className="max-w-md">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-6 w-6 text-destructive" />
|
||||
<CardTitle>Something went wrong</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
An unexpected error occurred. Please try again.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{this.state.error && (
|
||||
<pre className="rounded-lg bg-surface-input p-4 text-sm overflow-auto max-h-40">
|
||||
{this.state.error.message}
|
||||
</pre>
|
||||
)}
|
||||
<Button onClick={this.handleReset} className="w-full">
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Try Again
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
/**
|
||||
* Status Badge Component
|
||||
* Displays connection/state status with color coding
|
||||
*/
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
export type Status = 'connected' | 'disconnected' | 'connecting' | 'error' | 'running' | 'stopped' | 'starting' | 'reconnecting';
|
||||
|
||||
interface StatusBadgeProps {
|
||||
status: Status;
|
||||
label?: string;
|
||||
showDot?: boolean;
|
||||
}
|
||||
|
||||
const statusConfig: Record<Status, { label: string; variant: 'success' | 'secondary' | 'warning' | 'destructive' }> = {
|
||||
connected: { label: 'Connected', variant: 'success' },
|
||||
running: { label: 'Running', variant: 'success' },
|
||||
disconnected: { label: 'Disconnected', variant: 'secondary' },
|
||||
stopped: { label: 'Stopped', variant: 'secondary' },
|
||||
connecting: { label: 'Connecting', variant: 'warning' },
|
||||
starting: { label: 'Starting', variant: 'warning' },
|
||||
reconnecting: { label: 'Reconnecting', variant: 'warning' },
|
||||
error: { label: 'Error', variant: 'destructive' },
|
||||
};
|
||||
|
||||
export function StatusBadge({ status, label, showDot = true }: StatusBadgeProps) {
|
||||
const config = statusConfig[status];
|
||||
const displayLabel = label || config.label;
|
||||
|
||||
return (
|
||||
<Badge variant={config.variant} className="gap-1.5">
|
||||
{showDot && (
|
||||
<span
|
||||
className={cn(
|
||||
'h-1.5 w-1.5 rounded-full',
|
||||
config.variant === 'success' && 'bg-green-600',
|
||||
config.variant === 'secondary' && 'bg-muted-foreground',
|
||||
config.variant === 'warning' && 'bg-yellow-600 animate-pulse',
|
||||
config.variant === 'destructive' && 'bg-red-600'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{displayLabel}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ComponentRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 min-w-44 overflow-hidden rounded-md border border-border bg-surface-modal p-1 text-popover-foreground shadow-md',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ComponentRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors',
|
||||
'focus:bg-black/5 focus:text-foreground dark:focus:bg-white/10',
|
||||
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
};
|
||||
@@ -1,5 +1,4 @@
|
||||
export { rendererExtensionRegistry } from './registry';
|
||||
export { registerRendererExtensionModule, loadRendererExtensions } from './loader';
|
||||
export type {
|
||||
RendererExtension,
|
||||
NavItemDef,
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { rendererExtensionRegistry } from './registry';
|
||||
import type { RendererExtension } from './types';
|
||||
|
||||
interface RendererExtensionManifest {
|
||||
extensions?: {
|
||||
renderer?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
const registeredModules = new Map<string, () => RendererExtension>();
|
||||
|
||||
export function registerRendererExtensionModule(id: string, factory: () => RendererExtension): void {
|
||||
registeredModules.set(id, factory);
|
||||
}
|
||||
|
||||
export function loadRendererExtensions(manifest?: RendererExtensionManifest): void {
|
||||
const extensionIds = manifest?.extensions?.renderer;
|
||||
|
||||
if (!extensionIds || extensionIds.length === 0) {
|
||||
for (const [, factory] of registeredModules) {
|
||||
rendererExtensionRegistry.register(factory());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (const id of extensionIds) {
|
||||
const factory = registeredModules.get(id);
|
||||
if (factory) {
|
||||
rendererExtensionRegistry.register(factory());
|
||||
} else {
|
||||
console.warn(`[extensions] Renderer extension "${id}" not found in registered modules`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* A hook that ensures a loading state remains true for at least a minimum duration (e.g., 1000ms),
|
||||
* preventing flickering for very fast loading times.
|
||||
*
|
||||
* @param isLoading - The actual loading state from the data source
|
||||
* @param minDurationMs - Minimum duration to show loading (default: 1000)
|
||||
*/
|
||||
export function useMinLoading(isLoading: boolean, minDurationMs: number = 500) {
|
||||
const [showLoading, setShowLoading] = useState(isLoading);
|
||||
const startTime = useRef<number>(0);
|
||||
|
||||
// Guarantee that the loading UI activates immediately without any asynchronous delay
|
||||
if (isLoading && !showLoading) {
|
||||
setShowLoading(true);
|
||||
}
|
||||
|
||||
// Record the actual timestamp in an effect to respect React purity rules
|
||||
useEffect(() => {
|
||||
if (isLoading && startTime.current === 0) {
|
||||
startTime.current = Date.now();
|
||||
}
|
||||
}, [isLoading]);
|
||||
|
||||
useEffect(() => {
|
||||
let timeout: NodeJS.Timeout;
|
||||
|
||||
if (!isLoading && showLoading) {
|
||||
const elapsed = startTime.current > 0 ? Date.now() - startTime.current : 0;
|
||||
const remaining = Math.max(0, minDurationMs - elapsed);
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
setShowLoading(false);
|
||||
startTime.current = 0;
|
||||
}, remaining);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
};
|
||||
}, [isLoading, showLoading, minDurationMs]);
|
||||
|
||||
return isLoading || showLoading;
|
||||
}
|
||||
@@ -47,6 +47,14 @@ import type {
|
||||
AcpChatRespondPermissionPayload,
|
||||
} from '@shared/acp-chat/types';
|
||||
import type { CronJobCreateInput, CronJobUpdateInput } from '@shared/types/cron';
|
||||
import type {
|
||||
TalkAcknowledgeMarkPayload,
|
||||
TalkAppendAudioPayload,
|
||||
TalkRelayIdPayload,
|
||||
TalkStartAgentConsultPayload,
|
||||
TalkStartRelayPayload,
|
||||
TalkSubmitToolResultPayload,
|
||||
} from '@shared/talk/types';
|
||||
import { invokeHost } from './host-api-client';
|
||||
|
||||
export type {
|
||||
@@ -102,6 +110,13 @@ export type {
|
||||
WorkspaceNativeFileResult,
|
||||
WorkspaceOpenHandlersResult,
|
||||
} from '@shared/host-api/contract';
|
||||
export type {
|
||||
TalkAgentConsultResult,
|
||||
TalkCatalog,
|
||||
TalkOperationResult,
|
||||
TalkRelayEvent,
|
||||
TalkRelaySession,
|
||||
} from '@shared/talk/types';
|
||||
|
||||
export const hostApi = {
|
||||
app: {
|
||||
@@ -172,6 +187,16 @@ export const hostApi = {
|
||||
invokeHost('gateway', 'rpc', { method, params, timeoutMs }) as Promise<T>
|
||||
),
|
||||
},
|
||||
talk: {
|
||||
catalog: () => invokeHost('talk', 'catalog'),
|
||||
startRelay: (input: TalkStartRelayPayload) => invokeHost('talk', 'startRelay', input),
|
||||
appendAudio: (input: TalkAppendAudioPayload) => invokeHost('talk', 'appendAudio', input),
|
||||
cancelOutput: (input: TalkRelayIdPayload) => invokeHost('talk', 'cancelOutput', input),
|
||||
submitToolResult: (input: TalkSubmitToolResultPayload) => invokeHost('talk', 'submitToolResult', input),
|
||||
acknowledgeMark: (input: TalkAcknowledgeMarkPayload) => invokeHost('talk', 'acknowledgeMark', input),
|
||||
stopRelay: (input: TalkRelayIdPayload) => invokeHost('talk', 'stopRelay', input),
|
||||
startAgentConsult: (input: TalkStartAgentConsultPayload) => invokeHost('talk', 'startAgentConsult', input),
|
||||
},
|
||||
logs: {
|
||||
recent: (tailLines = 100) => invokeHost('logs', 'recent', { tailLines }),
|
||||
dir: () => invokeHost('logs', 'dir'),
|
||||
|
||||
@@ -40,6 +40,11 @@ const onChatEvent = <E extends HostEventName<'chat'>>(
|
||||
handler: HostEventHandler<'chat', E>,
|
||||
) => onIpc(HOST_EVENT_CHANNELS.chat[event], handler);
|
||||
|
||||
const onTalkEvent = <E extends HostEventName<'talk'>>(
|
||||
event: E,
|
||||
handler: HostEventHandler<'talk', E>,
|
||||
) => onIpc(HOST_EVENT_CHANNELS.talk[event], handler);
|
||||
|
||||
const onOAuthEvent = <E extends HostEventName<'oauth'>>(
|
||||
event: E,
|
||||
handler: HostEventHandler<'oauth', E>,
|
||||
@@ -98,6 +103,7 @@ export const hostEvents = {
|
||||
onAcpPermissionRequest: (handler: HostEventHandler<'chat', 'acpPermissionRequest'>) => (
|
||||
onChatEvent('acpPermissionRequest', handler)
|
||||
),
|
||||
onTalkEvent: (handler: HostEventHandler<'talk', 'event'>) => onTalkEvent('event', handler),
|
||||
onOAuthCode: (handler: HostEventHandler<'oauth', 'code'>) => onOAuthEvent('code', handler),
|
||||
onOAuthSuccess: (handler: HostEventHandler<'oauth', 'success'>) => onOAuthEvent('success', handler),
|
||||
onOAuthError: (handler: HostEventHandler<'oauth', 'error'>) => onOAuthEvent('error', handler),
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
export type MicrophoneBridge = {
|
||||
stop: () => void;
|
||||
};
|
||||
|
||||
const processorSource = `
|
||||
class ClawXTalkMicProcessor extends AudioWorkletProcessor {
|
||||
process(inputs) {
|
||||
const samples = inputs[0] && inputs[0][0];
|
||||
if (samples) {
|
||||
const copy = samples.slice();
|
||||
this.port.postMessage(copy, [copy.buffer]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
registerProcessor('clawx-talk-mic', ClawXTalkMicProcessor);
|
||||
`;
|
||||
|
||||
export async function createMicrophoneBridge(
|
||||
context: AudioContext,
|
||||
onSamples: (samples: Float32Array) => void,
|
||||
): Promise<MicrophoneBridge> {
|
||||
if (!navigator.mediaDevices?.getUserMedia || !context.audioWorklet) {
|
||||
throw new Error('Realtime microphone audio is not supported');
|
||||
}
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const url = URL.createObjectURL(new Blob([processorSource], { type: 'text/javascript' }));
|
||||
try {
|
||||
await context.audioWorklet.addModule(url);
|
||||
const source = context.createMediaStreamSource(stream);
|
||||
const worklet = new AudioWorkletNode(context, 'clawx-talk-mic');
|
||||
const silentOutput = context.createGain();
|
||||
silentOutput.gain.value = 0;
|
||||
worklet.port.onmessage = (event: MessageEvent<Float32Array>) => onSamples(event.data);
|
||||
source.connect(worklet);
|
||||
worklet.connect(silentOutput);
|
||||
silentOutput.connect(context.destination);
|
||||
return {
|
||||
stop: () => {
|
||||
source.disconnect();
|
||||
worklet.disconnect();
|
||||
silentOutput.disconnect();
|
||||
for (const track of stream.getTracks()) track.stop();
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
for (const track of stream.getTracks()) track.stop();
|
||||
throw error;
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
export function float32ToPcm16(samples: Float32Array): Int16Array {
|
||||
const pcm = new Int16Array(samples.length);
|
||||
for (let index = 0; index < samples.length; index += 1) {
|
||||
const sample = Math.max(-1, Math.min(1, samples[index] ?? 0));
|
||||
pcm[index] = Math.trunc(sample < 0 ? sample * 0x8000 : sample * 0x7fff);
|
||||
}
|
||||
return pcm;
|
||||
}
|
||||
|
||||
function pcm16ToBytes(samples: Int16Array): Uint8Array {
|
||||
const bytes = new Uint8Array(samples.length * 2);
|
||||
const view = new DataView(bytes.buffer);
|
||||
for (let index = 0; index < samples.length; index += 1) {
|
||||
view.setInt16(index * 2, samples[index] ?? 0, true);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function pcm16ToBase64(samples: Int16Array): string {
|
||||
const bytes = pcm16ToBytes(samples);
|
||||
let binary = '';
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
export function decodePcm16Base64(audioBase64: string): Int16Array {
|
||||
if (
|
||||
typeof audioBase64 !== 'string'
|
||||
|| !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(audioBase64)
|
||||
) throw new Error('Invalid PCM16 base64 audio');
|
||||
|
||||
let binary: string;
|
||||
try {
|
||||
binary = atob(audioBase64);
|
||||
} catch {
|
||||
throw new Error('Invalid PCM16 base64 audio');
|
||||
}
|
||||
if (binary.length === 0 || binary.length % 2 !== 0) throw new Error('Invalid PCM16 byte length');
|
||||
|
||||
const pcm = new Int16Array(binary.length / 2);
|
||||
for (let index = 0; index < pcm.length; index += 1) {
|
||||
const low = binary.charCodeAt(index * 2);
|
||||
const high = binary.charCodeAt(index * 2 + 1);
|
||||
pcm[index] = (high << 8) | low;
|
||||
}
|
||||
return pcm;
|
||||
}
|
||||
|
||||
export function pcm16ToFloat32(samples: Int16Array): Float32Array {
|
||||
const output = new Float32Array(samples.length);
|
||||
for (let index = 0; index < samples.length; index += 1) output[index] = (samples[index] ?? 0) / 0x8000;
|
||||
return output;
|
||||
}
|
||||
|
||||
export function inputLevel(samples: Float32Array): number {
|
||||
if (samples.length === 0) return 0;
|
||||
let sum = 0;
|
||||
for (const sample of samples) sum += sample * sample;
|
||||
return Math.min(1, Math.sqrt(sum / samples.length));
|
||||
}
|
||||
|
||||
export function resampleFloat32(
|
||||
samples: Float32Array,
|
||||
sourceSampleRateHz: number,
|
||||
targetSampleRateHz: number,
|
||||
): Float32Array {
|
||||
if (
|
||||
!Number.isInteger(sourceSampleRateHz)
|
||||
|| !Number.isInteger(targetSampleRateHz)
|
||||
|| sourceSampleRateHz < 8_000
|
||||
|| sourceSampleRateHz > 192_000
|
||||
|| targetSampleRateHz < 8_000
|
||||
|| targetSampleRateHz > 192_000
|
||||
) throw new Error('Unsupported Talk audio sample rate');
|
||||
if (sourceSampleRateHz === targetSampleRateHz) return samples;
|
||||
const length = Math.max(1, Math.round(samples.length * targetSampleRateHz / sourceSampleRateHz));
|
||||
const output = new Float32Array(length);
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const position = index * sourceSampleRateHz / targetSampleRateHz;
|
||||
const before = Math.floor(position);
|
||||
const after = Math.min(before + 1, samples.length - 1);
|
||||
const fraction = position - before;
|
||||
output[index] = (samples[before] ?? 0) * (1 - fraction) + (samples[after] ?? 0) * fraction;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export class Pcm16PlaybackQueue {
|
||||
private nextStartAt = 0;
|
||||
private sources = new Set<AudioBufferSourceNode>();
|
||||
|
||||
constructor(private readonly context: AudioContext, private readonly maxQueuedItems = 24) {}
|
||||
|
||||
enqueue(samples: Int16Array, sampleRate: number): Promise<void> {
|
||||
if (this.sources.size >= this.maxQueuedItems) return Promise.reject(new Error('Talk playback queue is full'));
|
||||
const buffer = this.context.createBuffer(1, samples.length, sampleRate);
|
||||
buffer.copyToChannel(new Float32Array(pcm16ToFloat32(samples)), 0);
|
||||
const source = this.context.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.connect(this.context.destination);
|
||||
const startAt = Math.max(this.context.currentTime, this.nextStartAt);
|
||||
this.nextStartAt = startAt + buffer.duration;
|
||||
this.sources.add(source);
|
||||
return new Promise((resolve) => {
|
||||
source.onended = () => {
|
||||
this.sources.delete(source);
|
||||
resolve();
|
||||
};
|
||||
source.start(startAt);
|
||||
});
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
for (const source of this.sources) source.stop();
|
||||
this.sources.clear();
|
||||
this.nextStartAt = this.context.currentTime;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
import type { TalkRelayEvent, TalkRelaySession } from '@shared/talk/types';
|
||||
import { DEFAULT_SESSION_KEY, type ChatSession } from '@shared/chat/types';
|
||||
import type { GatewayStatus } from '@shared/types/gateway';
|
||||
import { hostApi } from '@/lib/host-api';
|
||||
import { hostEvents } from '@/lib/host-events';
|
||||
import { useAcpChatSessionStore } from '@/stores/acp-chat-session';
|
||||
import { useChatStore } from '@/stores/chat';
|
||||
import { isOpenClawHeartbeatOnlySession } from '@/stores/chat/session-key-utils';
|
||||
import {
|
||||
registerRealtimeTalkCleanup,
|
||||
useRealtimeTalkStore,
|
||||
} from '@/stores/realtime-talk';
|
||||
import {
|
||||
decodePcm16Base64,
|
||||
float32ToPcm16,
|
||||
inputLevel,
|
||||
Pcm16PlaybackQueue,
|
||||
pcm16ToBase64,
|
||||
resampleFloat32,
|
||||
} from './audio';
|
||||
import { createMicrophoneBridge, type MicrophoneBridge } from './audio-worklet';
|
||||
|
||||
type TalkApi = Pick<typeof hostApi.talk,
|
||||
'startRelay' | 'appendAudio' | 'cancelOutput' | 'acknowledgeMark' | 'stopRelay' | 'startAgentConsult' | 'submitToolResult'>;
|
||||
|
||||
type TalkAudioTransport = {
|
||||
start: (onSamples: (samples: Float32Array, sampleRateHz: number) => void) => Promise<void>;
|
||||
stop: () => void;
|
||||
enqueueOutput: (samples: Int16Array, sampleRate: number) => Promise<void>;
|
||||
clearOutput: () => void;
|
||||
};
|
||||
|
||||
type ControllerDependencies = {
|
||||
talk: TalkApi;
|
||||
subscribeTalk: (listener: (event: TalkRelayEvent) => void) => () => void;
|
||||
subscribeGatewayStatus: (listener: (status: GatewayStatus) => void) => () => void;
|
||||
createAudio: () => TalkAudioTransport;
|
||||
getAcpState: () => { sending: boolean; activeSessionKey: string | null };
|
||||
/** Test seam; production derives heartbeat status from the selected Chat session. */
|
||||
isHeartbeatSession?: (sessionKey: string) => boolean;
|
||||
reloadAcpSession: () => Promise<boolean>;
|
||||
};
|
||||
|
||||
type StartInput = { sessionKey: string; heartbeatOnly?: boolean };
|
||||
type StartAttempt = { controller: RealtimeTalkController; id: number };
|
||||
|
||||
let nextStartAttemptId = 0;
|
||||
let currentStartAttempt: StartAttempt | null = null;
|
||||
|
||||
function isSupportedSampleRate(sampleRateHz: number): boolean {
|
||||
return Number.isInteger(sampleRateHz) && sampleRateHz >= 8_000 && sampleRateHz <= 192_000;
|
||||
}
|
||||
|
||||
function isSupportedAudio(session: TalkRelaySession): boolean {
|
||||
const { audio } = session;
|
||||
return audio.inputEncoding === 'pcm16'
|
||||
&& audio.outputEncoding === 'pcm16'
|
||||
&& isSupportedSampleRate(audio.inputSampleRateHz)
|
||||
&& isSupportedSampleRate(audio.outputSampleRateHz);
|
||||
}
|
||||
|
||||
class BrowserTalkAudioTransport implements TalkAudioTransport {
|
||||
private context: AudioContext | null = null;
|
||||
private microphone: MicrophoneBridge | null = null;
|
||||
private playback: Pcm16PlaybackQueue | null = null;
|
||||
|
||||
async start(onSamples: (samples: Float32Array, sampleRateHz: number) => void): Promise<void> {
|
||||
const AudioContextConstructor = window.AudioContext;
|
||||
if (!AudioContextConstructor) throw new Error('Realtime audio is not supported');
|
||||
this.context = new AudioContextConstructor();
|
||||
this.playback = new Pcm16PlaybackQueue(this.context);
|
||||
this.microphone = await createMicrophoneBridge(this.context, (samples) => {
|
||||
onSamples(samples, this.context?.sampleRate ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.microphone?.stop();
|
||||
this.microphone = null;
|
||||
this.playback?.clear();
|
||||
this.playback = null;
|
||||
void this.context?.close();
|
||||
this.context = null;
|
||||
}
|
||||
|
||||
enqueueOutput(samples: Int16Array, sampleRate: number): Promise<void> {
|
||||
if (!this.playback) return Promise.reject(new Error('Talk playback is unavailable'));
|
||||
return this.playback.enqueue(samples, sampleRate);
|
||||
}
|
||||
|
||||
clearOutput(): void {
|
||||
this.playback?.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export class RealtimeTalkController {
|
||||
private relaySessionId: string | null = null;
|
||||
private sessionKey: string | null = null;
|
||||
private unsubscribeTalk: (() => void) | null = null;
|
||||
private unsubscribeGateway: (() => void) | null = null;
|
||||
private unregisterCleanup: (() => void) | null = null;
|
||||
private cleanupAttempt: StartAttempt | null = null;
|
||||
private audio: TalkAudioTransport | null = null;
|
||||
private appendQueue = Promise.resolve();
|
||||
private queuedAppendCount = 0;
|
||||
private inputSampleRateHz = 24_000;
|
||||
private outputSampleRateHz = 24_000;
|
||||
private outputPlayback = Promise.resolve();
|
||||
private outputTurnActive = false;
|
||||
private outputTurnGeneration = 0;
|
||||
private outputCancelled = false;
|
||||
private consultCallIds = new Set<string>();
|
||||
|
||||
constructor(private readonly dependencies: ControllerDependencies) {}
|
||||
|
||||
async start(input: StartInput): Promise<boolean> {
|
||||
const sessionKey = input.sessionKey.trim();
|
||||
const acp = this.dependencies.getAcpState();
|
||||
const heartbeat = input.heartbeatOnly || this.dependencies.isHeartbeatSession?.(sessionKey) || false;
|
||||
if (
|
||||
useRealtimeTalkStore.getState().isActive
|
||||
|| !sessionKey
|
||||
|| sessionKey === DEFAULT_SESSION_KEY
|
||||
|| sessionKey !== acp.activeSessionKey
|
||||
|| heartbeat
|
||||
) {
|
||||
useRealtimeTalkStore.getState().finish('error', 'Talk requires the selected non-heartbeat session');
|
||||
return false;
|
||||
}
|
||||
if (acp.sending) {
|
||||
useRealtimeTalkStore.getState().finish('error', 'An ACP prompt is already in progress');
|
||||
return false;
|
||||
}
|
||||
|
||||
const attempt: StartAttempt = { controller: this, id: nextStartAttemptId += 1 };
|
||||
this.clearCleanupAttempt();
|
||||
this.unregisterCleanup = registerRealtimeTalkCleanup(() => this.stop());
|
||||
this.cleanupAttempt = attempt;
|
||||
currentStartAttempt = attempt;
|
||||
useRealtimeTalkStore.getState().setStatus('connecting');
|
||||
let relay: TalkRelaySession | null = null;
|
||||
let audio: TalkAudioTransport | null = null;
|
||||
let unsubscribeTalk: (() => void) | null = null;
|
||||
let unsubscribeGateway: (() => void) | null = null;
|
||||
try {
|
||||
relay = await this.dependencies.talk.startRelay({ sessionKey });
|
||||
if (!this.isCurrentAttempt(attempt)) {
|
||||
await this.closeRelay(relay.relaySessionId);
|
||||
this.clearCleanupAttempt(attempt);
|
||||
return false;
|
||||
}
|
||||
if (!isSupportedAudio(relay)) throw new Error('Unsupported Talk audio contract');
|
||||
audio = this.dependencies.createAudio();
|
||||
unsubscribeTalk = this.dependencies.subscribeTalk((event) => this.handleEvent(event));
|
||||
unsubscribeGateway = this.dependencies.subscribeGatewayStatus((status) => {
|
||||
if (status.state !== 'running' || status.gatewayReady === false) void this.handleGatewayDisconnect();
|
||||
});
|
||||
await audio.start((samples, sampleRateHz) => this.handleInput(samples, sampleRateHz));
|
||||
if (!this.isCurrentAttempt(attempt)) {
|
||||
unsubscribeTalk();
|
||||
unsubscribeGateway();
|
||||
audio.stop();
|
||||
await this.closeRelay(relay.relaySessionId);
|
||||
this.clearCleanupAttempt(attempt);
|
||||
return false;
|
||||
}
|
||||
|
||||
this.relaySessionId = relay.relaySessionId;
|
||||
this.sessionKey = sessionKey;
|
||||
this.inputSampleRateHz = relay.audio.inputSampleRateHz;
|
||||
this.outputSampleRateHz = relay.audio.outputSampleRateHz;
|
||||
this.audio = audio;
|
||||
this.unsubscribeTalk = unsubscribeTalk;
|
||||
this.unsubscribeGateway = unsubscribeGateway;
|
||||
currentStartAttempt = null;
|
||||
useRealtimeTalkStore.getState().begin(relay.relaySessionId, sessionKey);
|
||||
return true;
|
||||
} catch (error) {
|
||||
unsubscribeTalk?.();
|
||||
unsubscribeGateway?.();
|
||||
audio?.stop();
|
||||
if (relay) await this.closeRelay(relay.relaySessionId);
|
||||
if (this.isCurrentAttempt(attempt)) {
|
||||
currentStartAttempt = null;
|
||||
this.clearCleanupAttempt(attempt);
|
||||
useRealtimeTalkStore.getState().finish('error', error instanceof Error ? error.message : 'Talk could not start');
|
||||
} else {
|
||||
this.clearCleanupAttempt(attempt);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
await this.cleanup('cancelled');
|
||||
}
|
||||
|
||||
async handleSessionChange(sessionKey: string): Promise<void> {
|
||||
if (this.sessionKey && this.sessionKey !== sessionKey) await this.cleanup('cancelled');
|
||||
}
|
||||
|
||||
async handleGatewayDisconnect(): Promise<void> {
|
||||
await this.cleanup('disconnected');
|
||||
}
|
||||
|
||||
private isCurrentAttempt(attempt: StartAttempt): boolean {
|
||||
return currentStartAttempt?.controller === attempt.controller && currentStartAttempt.id === attempt.id;
|
||||
}
|
||||
|
||||
private clearCleanupAttempt(attempt?: StartAttempt): void {
|
||||
if (attempt && this.cleanupAttempt !== attempt) return;
|
||||
this.unregisterCleanup?.();
|
||||
this.unregisterCleanup = null;
|
||||
this.cleanupAttempt = null;
|
||||
}
|
||||
|
||||
private handleInput(samples: Float32Array, captureSampleRateHz: number): void {
|
||||
const relaySessionId = this.relaySessionId;
|
||||
if (!relaySessionId) return;
|
||||
const level = inputLevel(samples);
|
||||
useRealtimeTalkStore.getState().setInputLevel(level);
|
||||
if (this.outputTurnActive && !this.outputCancelled && level >= 0.05) {
|
||||
this.outputCancelled = true;
|
||||
this.clearOutputTurn();
|
||||
void this.dependencies.talk.cancelOutput({ relaySessionId }).catch(() => undefined);
|
||||
}
|
||||
if (this.queuedAppendCount >= 4) return;
|
||||
let resampled: Float32Array;
|
||||
try {
|
||||
resampled = resampleFloat32(samples, captureSampleRateHz, this.inputSampleRateHz);
|
||||
} catch (error) {
|
||||
void this.cleanup('error', error instanceof Error ? error.message : 'Unsupported Talk audio sample rate');
|
||||
return;
|
||||
}
|
||||
this.queuedAppendCount += 1;
|
||||
const audioBase64 = pcm16ToBase64(float32ToPcm16(resampled));
|
||||
this.appendQueue = this.appendQueue
|
||||
.then(async () => {
|
||||
if (this.relaySessionId !== relaySessionId) return;
|
||||
await this.dependencies.talk.appendAudio({ relaySessionId, audioBase64, timestamp: Date.now() });
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => { this.queuedAppendCount -= 1; });
|
||||
}
|
||||
|
||||
private handleEvent(event: TalkRelayEvent): void {
|
||||
if (event.relaySessionId !== this.relaySessionId) return;
|
||||
switch (event.type) {
|
||||
case 'ready':
|
||||
useRealtimeTalkStore.getState().setStatus('listening');
|
||||
break;
|
||||
case 'transcript':
|
||||
useRealtimeTalkStore.getState().appendTranscript(event);
|
||||
break;
|
||||
case 'audio':
|
||||
this.playOutput(event.audioBase64);
|
||||
break;
|
||||
case 'clear':
|
||||
this.clearOutputTurn();
|
||||
useRealtimeTalkStore.getState().setStatus('listening');
|
||||
break;
|
||||
case 'mark':
|
||||
this.completeOutputTurn(event.markName);
|
||||
break;
|
||||
case 'toolCall':
|
||||
void this.startAgentConsult(event);
|
||||
break;
|
||||
case 'toolResult':
|
||||
if (event.final && this.consultCallIds.delete(event.callId)) void this.dependencies.reloadAcpSession();
|
||||
break;
|
||||
case 'toolCallCancelled':
|
||||
this.consultCallIds.delete(event.callId);
|
||||
break;
|
||||
case 'error':
|
||||
void this.cleanup('error', event.message);
|
||||
break;
|
||||
case 'close':
|
||||
void this.cleanup(event.reason, event.reason === 'error' ? 'Talk relay closed with an error' : undefined);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private playOutput(audioBase64: string): void {
|
||||
const relaySessionId = this.relaySessionId;
|
||||
if (!relaySessionId || !this.audio) return;
|
||||
let samples: Int16Array;
|
||||
try {
|
||||
samples = decodePcm16Base64(audioBase64);
|
||||
} catch (error) {
|
||||
void this.cleanup('error', error instanceof Error ? error.message : 'Invalid Talk audio');
|
||||
return;
|
||||
}
|
||||
if (!this.outputTurnActive) {
|
||||
this.outputTurnActive = true;
|
||||
this.outputCancelled = false;
|
||||
this.outputTurnGeneration += 1;
|
||||
useRealtimeTalkStore.getState().setStatus('speaking');
|
||||
}
|
||||
const generation = this.outputTurnGeneration;
|
||||
this.outputPlayback = this.outputPlayback
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
if (!this.isCurrentOutputTurn(generation, relaySessionId)) return;
|
||||
await this.audio?.enqueueOutput(samples, this.outputSampleRateHz);
|
||||
});
|
||||
}
|
||||
|
||||
private completeOutputTurn(markName: string): void {
|
||||
const relaySessionId = this.relaySessionId;
|
||||
const generation = this.outputTurnGeneration;
|
||||
if (!relaySessionId) return;
|
||||
if (!this.outputTurnActive) {
|
||||
void this.outputPlayback
|
||||
.catch(() => undefined)
|
||||
.then(() => this.acknowledgeMark(markName));
|
||||
return;
|
||||
}
|
||||
void this.outputPlayback
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
if (!this.isCurrentOutputTurn(generation, relaySessionId)) return;
|
||||
await this.acknowledgeMark(markName);
|
||||
if (!this.isCurrentOutputTurn(generation, relaySessionId)) return;
|
||||
this.outputTurnActive = false;
|
||||
useRealtimeTalkStore.getState().setStatus('listening');
|
||||
});
|
||||
}
|
||||
|
||||
private isCurrentOutputTurn(generation: number, relaySessionId: string): boolean {
|
||||
return this.outputTurnActive
|
||||
&& this.outputTurnGeneration === generation
|
||||
&& this.relaySessionId === relaySessionId;
|
||||
}
|
||||
|
||||
private clearOutputTurn(): void {
|
||||
this.outputTurnGeneration += 1;
|
||||
this.outputTurnActive = false;
|
||||
this.outputCancelled = false;
|
||||
this.audio?.clearOutput();
|
||||
}
|
||||
|
||||
private async acknowledgeMark(markName: string): Promise<void> {
|
||||
const relaySessionId = this.relaySessionId;
|
||||
if (!relaySessionId) return;
|
||||
await this.dependencies.talk.acknowledgeMark({ relaySessionId, markName }).catch(() => undefined);
|
||||
}
|
||||
|
||||
private async startAgentConsult(event: Extract<TalkRelayEvent, { type: 'toolCall' }>): Promise<void> {
|
||||
const relaySessionId = this.relaySessionId;
|
||||
const sessionKey = this.sessionKey;
|
||||
if (!relaySessionId || !sessionKey) return;
|
||||
this.consultCallIds.add(event.callId);
|
||||
useRealtimeTalkStore.getState().setStatus('thinking');
|
||||
try {
|
||||
const result = await this.dependencies.talk.startAgentConsult({ relaySessionId, sessionKey, callId: event.callId, args: event.args });
|
||||
await this.dependencies.talk.submitToolResult({
|
||||
relaySessionId,
|
||||
callId: event.callId,
|
||||
result,
|
||||
options: { suppressResponse: true, willContinue: true },
|
||||
});
|
||||
} catch (error) {
|
||||
this.consultCallIds.delete(event.callId);
|
||||
await this.cleanup('error', error instanceof Error ? error.message : 'Agent consult failed');
|
||||
}
|
||||
}
|
||||
|
||||
private async cleanup(reason: 'cancelled' | 'completed' | 'error' | 'disconnected' | (string & {}), error?: string): Promise<void> {
|
||||
if (currentStartAttempt?.controller === this) currentStartAttempt = null;
|
||||
const relaySessionId = this.relaySessionId;
|
||||
this.releaseLocalResources();
|
||||
useRealtimeTalkStore.getState().finish(reason, error);
|
||||
if (relaySessionId) await this.closeRelay(relaySessionId);
|
||||
}
|
||||
|
||||
private async closeRelay(relaySessionId: string): Promise<void> {
|
||||
await this.dependencies.talk.stopRelay({ relaySessionId }).catch(() => undefined);
|
||||
}
|
||||
|
||||
private releaseLocalResources(): void {
|
||||
this.clearCleanupAttempt();
|
||||
this.unsubscribeTalk?.();
|
||||
this.unsubscribeTalk = null;
|
||||
this.unsubscribeGateway?.();
|
||||
this.unsubscribeGateway = null;
|
||||
this.clearOutputTurn();
|
||||
this.audio?.stop();
|
||||
this.audio = null;
|
||||
this.relaySessionId = null;
|
||||
this.sessionKey = null;
|
||||
this.appendQueue = Promise.resolve();
|
||||
this.inputSampleRateHz = 24_000;
|
||||
this.outputSampleRateHz = 24_000;
|
||||
this.queuedAppendCount = 0;
|
||||
this.consultCallIds.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function isSelectedHeartbeatSession(sessionKey: string): boolean {
|
||||
const session = useChatStore.getState().sessions.find((candidate: ChatSession) => candidate.key === sessionKey);
|
||||
return !!session && isOpenClawHeartbeatOnlySession(session);
|
||||
}
|
||||
|
||||
export function createRealtimeTalkController(dependencies: ControllerDependencies): RealtimeTalkController {
|
||||
return new RealtimeTalkController(dependencies);
|
||||
}
|
||||
|
||||
export const realtimeTalkController = createRealtimeTalkController({
|
||||
talk: hostApi.talk,
|
||||
subscribeTalk: hostEvents.onTalkEvent,
|
||||
subscribeGatewayStatus: hostEvents.onGatewayStatus,
|
||||
createAudio: () => new BrowserTalkAudioTransport(),
|
||||
getAcpState: () => {
|
||||
const state = useAcpChatSessionStore.getState();
|
||||
return { sending: state.sending, activeSessionKey: state.activeSessionKey };
|
||||
},
|
||||
isHeartbeatSession: isSelectedHeartbeatSession,
|
||||
reloadAcpSession: () => useAcpChatSessionStore.getState().reloadActiveSession(),
|
||||
});
|
||||
@@ -45,6 +45,7 @@ import { hostApi } from '@/lib/host-api';
|
||||
import { hostEvents } from '@/lib/host-events';
|
||||
import type { AcpTimelineSnapshot, MessageSegmentItem, PermissionItem, RenderPart } from '@/lib/acp/timeline-types';
|
||||
import { isCronSessionKey } from './chat/cron-session-utils';
|
||||
import { stopActiveRealtimeTalk, useRealtimeTalkStore } from './realtime-talk';
|
||||
|
||||
const EMPTY_SESSION_ID = '';
|
||||
const CANCEL_PERMISSION_OPTION_ID = '__cancelled__';
|
||||
@@ -147,6 +148,7 @@ export type AcpChatSessionState = {
|
||||
turnTimingsByUserMessageId: Record<string, AcpTurnTiming>;
|
||||
prepareLocalSession: (input: AcpChatLoadPayload) => void;
|
||||
loadSession: (input: AcpChatLoadPayload) => Promise<boolean>;
|
||||
reloadActiveSession: () => Promise<boolean>;
|
||||
sendPrompt: (input: AcpChatPromptPayload) => Promise<boolean>;
|
||||
cancel: () => Promise<void>;
|
||||
respondPermission: (requestId: string, optionId: string) => Promise<void>;
|
||||
@@ -1016,6 +1018,8 @@ export const useAcpChatSessionStore = create<AcpChatSessionState>((set, get) =>
|
||||
turnTimingsByUserMessageId: {},
|
||||
|
||||
prepareLocalSession(input) {
|
||||
void stopActiveRealtimeTalk();
|
||||
useRealtimeTalkStore.getState().reset();
|
||||
captureLiveSession(get());
|
||||
loadRequestSeq += 1;
|
||||
pendingLoadUpdates.clear();
|
||||
@@ -1061,6 +1065,8 @@ export const useAcpChatSessionStore = create<AcpChatSessionState>((set, get) =>
|
||||
timeline: liveSnapshot?.timeline ?? createEmptyAcpTimeline(input.sessionKey, generation),
|
||||
turnTimingsByUserMessageId: liveSnapshot?.turnTimingsByUserMessageId ?? {},
|
||||
});
|
||||
await stopActiveRealtimeTalk();
|
||||
useRealtimeTalkStore.getState().reset();
|
||||
|
||||
try {
|
||||
let result = await hostApi.chat.loadAcpSession(input);
|
||||
@@ -1223,11 +1229,18 @@ export const useAcpChatSessionStore = create<AcpChatSessionState>((set, get) =>
|
||||
}
|
||||
},
|
||||
|
||||
async reloadActiveSession() {
|
||||
const { activeSessionKey, workspaceRoot, cwd } = get();
|
||||
if (!activeSessionKey || !workspaceRoot || !cwd) return false;
|
||||
return await get().loadSession({ sessionKey: activeSessionKey, workspaceRoot, cwd });
|
||||
},
|
||||
|
||||
async sendPrompt(input) {
|
||||
const startState = get();
|
||||
const sessionKey = input.sessionKey;
|
||||
const generation = startState.generation;
|
||||
if (startState.activeSessionKey !== sessionKey) return false;
|
||||
if (useRealtimeTalkStore.getState().isActive) return false;
|
||||
|
||||
const messageId = input.messageId ?? createOptimisticMessageId();
|
||||
const payload = { ...input, messageId };
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { create } from 'zustand';
|
||||
import type { TalkTerminalReason } from '@shared/talk/types';
|
||||
|
||||
let activeRelayCleanup: (() => Promise<void>) | null = null;
|
||||
|
||||
// ACP owns reload boundaries, but the controller owns the browser resources it must release.
|
||||
export function registerRealtimeTalkCleanup(cleanup: () => Promise<void>): () => void {
|
||||
activeRelayCleanup = cleanup;
|
||||
return () => {
|
||||
if (activeRelayCleanup === cleanup) activeRelayCleanup = null;
|
||||
};
|
||||
}
|
||||
|
||||
export async function stopActiveRealtimeTalk(): Promise<void> {
|
||||
await activeRelayCleanup?.();
|
||||
}
|
||||
|
||||
export type RealtimeTalkStatus = 'idle' | 'connecting' | 'listening' | 'thinking' | 'speaking' | 'disconnected' | 'error';
|
||||
|
||||
export type LiveTalkTranscript = {
|
||||
role: 'user' | 'assistant';
|
||||
text: string;
|
||||
final: boolean;
|
||||
};
|
||||
|
||||
type RealtimeTalkState = {
|
||||
status: RealtimeTalkStatus;
|
||||
relaySessionId: string | null;
|
||||
sessionKey: string | null;
|
||||
inputLevel: number;
|
||||
transcripts: LiveTalkTranscript[];
|
||||
error: string | null;
|
||||
isActive: boolean;
|
||||
begin: (relaySessionId: string, sessionKey: string) => void;
|
||||
setStatus: (status: RealtimeTalkStatus) => void;
|
||||
setInputLevel: (inputLevel: number) => void;
|
||||
appendTranscript: (entry: LiveTalkTranscript) => void;
|
||||
finish: (reason: TalkTerminalReason, error?: string) => void;
|
||||
reset: () => void;
|
||||
};
|
||||
|
||||
const initialState = {
|
||||
status: 'idle' as const,
|
||||
relaySessionId: null,
|
||||
sessionKey: null,
|
||||
inputLevel: 0,
|
||||
transcripts: [] as LiveTalkTranscript[],
|
||||
error: null,
|
||||
isActive: false,
|
||||
};
|
||||
|
||||
export const useRealtimeTalkStore = create<RealtimeTalkState>((set) => ({
|
||||
...initialState,
|
||||
begin: (relaySessionId, sessionKey) => set({
|
||||
relaySessionId,
|
||||
sessionKey,
|
||||
status: 'listening',
|
||||
error: null,
|
||||
inputLevel: 0,
|
||||
transcripts: [],
|
||||
isActive: true,
|
||||
}),
|
||||
setStatus: (status) => set({ status }),
|
||||
setInputLevel: (inputLevel) => set({ inputLevel: Math.max(0, Math.min(1, inputLevel)) }),
|
||||
appendTranscript: (entry) => set((state) => {
|
||||
const last = state.transcripts.at(-1);
|
||||
const transcripts = !entry.final && last && last.role === entry.role && !last.final
|
||||
? [...state.transcripts.slice(0, -1), entry]
|
||||
: entry.final && last && last.role === entry.role && !last.final
|
||||
? [...state.transcripts.slice(0, -1), entry]
|
||||
: [...state.transcripts, entry];
|
||||
return { transcripts };
|
||||
}),
|
||||
finish: (reason, error) => set({
|
||||
...initialState,
|
||||
status: error ? 'error' : reason === 'disconnected' ? 'disconnected' : 'idle',
|
||||
error: error ?? null,
|
||||
}),
|
||||
reset: () => set(initialState),
|
||||
}));
|
||||
@@ -166,6 +166,36 @@ describe('ACP Chat store', () => {
|
||||
hostEventsMock.onChatRuntimeEvent.mockClear();
|
||||
});
|
||||
|
||||
it('does not send an ACP prompt while a realtime Talk relay is active', async () => {
|
||||
const { useAcpChatSessionStore } = await importStore();
|
||||
const { useRealtimeTalkStore } = await import('@/stores/realtime-talk');
|
||||
useAcpChatSessionStore.getState().prepareLocalSession({
|
||||
sessionKey: 'agent:pi:s1', workspaceRoot: '/repo-a', cwd: '/repo-a',
|
||||
});
|
||||
useRealtimeTalkStore.getState().begin('relay-1', 'agent:pi:s1');
|
||||
|
||||
await expect(useAcpChatSessionStore.getState().sendPrompt({
|
||||
sessionKey: 'agent:pi:s1', cwd: '/repo-a', message: 'blocked',
|
||||
})).resolves.toBe(false);
|
||||
expect(hostApiMock.sendAcpPrompt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops the active realtime Talk controller before reloading ACP state', async () => {
|
||||
const { useAcpChatSessionStore } = await importStore();
|
||||
const { registerRealtimeTalkCleanup, useRealtimeTalkStore } = await import('@/stores/realtime-talk');
|
||||
const stopRelay = vi.fn().mockResolvedValue(undefined);
|
||||
const unregister = registerRealtimeTalkCleanup(stopRelay);
|
||||
useRealtimeTalkStore.getState().begin('relay-1', 'agent:pi:s1');
|
||||
|
||||
await useAcpChatSessionStore.getState().loadSession({
|
||||
sessionKey: 'agent:pi:s1', workspaceRoot: '/repo-a', cwd: '/repo-a',
|
||||
});
|
||||
|
||||
expect(stopRelay).toHaveBeenCalledOnce();
|
||||
expect(useRealtimeTalkStore.getState().isActive).toBe(false);
|
||||
unregister();
|
||||
});
|
||||
|
||||
it('projects cron history when ACP replay is empty', async () => {
|
||||
hostApiMock.cronSessionHistory.mockResolvedValue({
|
||||
messages: [
|
||||
|
||||
@@ -115,10 +115,6 @@ vi.mock('@/hooks/use-stick-to-bottom-instant', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-min-loading', () => ({
|
||||
useMinLoading: () => false,
|
||||
}));
|
||||
|
||||
vi.mock('@/pages/Chat/ChatToolbar', () => ({
|
||||
ChatToolbar: () => null,
|
||||
}));
|
||||
|
||||
@@ -137,10 +137,6 @@ vi.mock('@/hooks/use-stick-to-bottom-instant', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-min-loading', () => ({
|
||||
useMinLoading: () => false,
|
||||
}));
|
||||
|
||||
vi.mock('@/pages/Chat/ChatToolbar', () => ({
|
||||
ChatToolbar: () => <div data-testid="mock-chat-toolbar" />,
|
||||
}));
|
||||
|
||||
@@ -71,10 +71,6 @@ vi.mock('@/hooks/use-stick-to-bottom-instant', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-min-loading', () => ({
|
||||
useMinLoading: (value: boolean) => value,
|
||||
}));
|
||||
|
||||
vi.mock('@/pages/Chat/ChatInput', () => ({
|
||||
ChatInput: () => <div data-testid="chat-input" />,
|
||||
}));
|
||||
|
||||
@@ -97,10 +97,6 @@ vi.mock('@/hooks/use-stick-to-bottom-instant', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-min-loading', () => ({
|
||||
useMinLoading: () => false,
|
||||
}));
|
||||
|
||||
vi.mock('@/pages/Chat/ChatToolbar', () => ({ ChatToolbar: () => null }));
|
||||
vi.mock('@/pages/Chat/ChatInput', () => ({ ChatInput: () => null }));
|
||||
|
||||
|
||||
@@ -102,10 +102,6 @@ vi.mock('@/hooks/use-stick-to-bottom-instant', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-min-loading', () => ({
|
||||
useMinLoading: () => false,
|
||||
}));
|
||||
|
||||
vi.mock('@/pages/Chat/ChatToolbar', () => ({ ChatToolbar: () => null }));
|
||||
vi.mock('@/pages/Chat/ChatInput', () => ({ ChatInput: () => null }));
|
||||
|
||||
|
||||
@@ -102,10 +102,6 @@ vi.mock('@/hooks/use-stick-to-bottom-instant', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-min-loading', () => ({
|
||||
useMinLoading: () => false,
|
||||
}));
|
||||
|
||||
vi.mock('@/pages/Chat/ChatInput', () => ({
|
||||
ChatInput: () => null,
|
||||
}));
|
||||
|
||||
@@ -102,10 +102,6 @@ vi.mock('@/hooks/use-stick-to-bottom-instant', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-min-loading', () => ({
|
||||
useMinLoading: () => false,
|
||||
}));
|
||||
|
||||
vi.mock('@/pages/Chat/ChatToolbar', () => ({
|
||||
ChatToolbar: () => null,
|
||||
}));
|
||||
|
||||
@@ -48,6 +48,16 @@ describe('dispatchProtocolEvent', () => {
|
||||
expect(emitter.emit).toHaveBeenCalledWith('chat:message', { message: { text: 'hello' } });
|
||||
});
|
||||
|
||||
it('dispatches talk.event to talk:event without the generic notification path', () => {
|
||||
const emitter = createMockEmitter();
|
||||
const payload = { relaySessionId: 'relay-1', type: 'transcript', role: 'assistant', text: 'Hello' };
|
||||
|
||||
dispatchProtocolEvent(emitter, 'talk.event', payload);
|
||||
|
||||
expect(emitter.emit).toHaveBeenCalledWith('talk:event', payload);
|
||||
expect(emitter.emit).not.toHaveBeenCalledWith('notification', expect.anything());
|
||||
});
|
||||
|
||||
it('does not normalize non-terminal lifecycle phase=end as run.ended', () => {
|
||||
const emitter = createMockEmitter();
|
||||
const payload = {
|
||||
|
||||
@@ -472,6 +472,36 @@ describe('hostApi facade', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it('routes Talk actions through the typed host facade', async () => {
|
||||
hostInvoke.mockResolvedValue({ id: 'req', ok: true, data: { ok: true } });
|
||||
const { hostApi } = await import('@/lib/host-api');
|
||||
|
||||
await hostApi.talk.catalog();
|
||||
await hostApi.talk.startRelay({ sessionKey: 'agent:main:session-1' });
|
||||
await hostApi.talk.appendAudio({ relaySessionId: 'relay-1', audioBase64: 'AQI=' });
|
||||
await hostApi.talk.cancelOutput({ relaySessionId: 'relay-1' });
|
||||
await hostApi.talk.submitToolResult({ relaySessionId: 'relay-1', callId: 'call-1', result: { ok: true } });
|
||||
await hostApi.talk.acknowledgeMark({ relaySessionId: 'relay-1', markName: 'mark-1' });
|
||||
await hostApi.talk.stopRelay({ relaySessionId: 'relay-1' });
|
||||
await hostApi.talk.startAgentConsult({
|
||||
relaySessionId: 'relay-1',
|
||||
sessionKey: 'agent:main:session-1',
|
||||
callId: 'call-1',
|
||||
args: {},
|
||||
});
|
||||
|
||||
expect(hostInvoke.mock.calls.map(([request]) => request)).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ module: 'talk', action: 'catalog' }),
|
||||
expect.objectContaining({ module: 'talk', action: 'startRelay', payload: { sessionKey: 'agent:main:session-1' } }),
|
||||
expect.objectContaining({ module: 'talk', action: 'appendAudio', payload: { relaySessionId: 'relay-1', audioBase64: 'AQI=' } }),
|
||||
expect.objectContaining({ module: 'talk', action: 'cancelOutput' }),
|
||||
expect.objectContaining({ module: 'talk', action: 'submitToolResult' }),
|
||||
expect.objectContaining({ module: 'talk', action: 'acknowledgeMark' }),
|
||||
expect.objectContaining({ module: 'talk', action: 'stopRelay' }),
|
||||
expect.objectContaining({ module: 'talk', action: 'startAgentConsult' }),
|
||||
]));
|
||||
});
|
||||
|
||||
it('calls sessions.summaries through hostInvoke', async () => {
|
||||
hostInvoke.mockResolvedValueOnce({ id: 'req', ok: true, data: { success: true, summaries: [] } });
|
||||
const { hostApi } = await import('@/lib/host-api');
|
||||
|
||||
@@ -74,6 +74,18 @@ describe('hostEvents', () => {
|
||||
expect(on).toHaveBeenCalledWith('channel:wechat-qr', expect.any(Function));
|
||||
});
|
||||
|
||||
it('subscribes to Talk relay events over the dedicated IPC channel', async () => {
|
||||
const { hostEvents } = await import('@/lib/host-events');
|
||||
const handler = vi.fn();
|
||||
|
||||
hostEvents.onTalkEvent(handler);
|
||||
const callback = on.mock.calls[0]?.[1] as ((payload: unknown) => void) | undefined;
|
||||
callback?.({ relaySessionId: 'relay-1', type: 'ready' });
|
||||
|
||||
expect(on).toHaveBeenCalledWith('talk:event', expect.any(Function));
|
||||
expect(handler).toHaveBeenCalledWith({ relaySessionId: 'relay-1', type: 'ready' });
|
||||
});
|
||||
|
||||
it('does not create EventSource fallback', async () => {
|
||||
const eventSource = vi.fn();
|
||||
vi.stubGlobal('EventSource', eventSource);
|
||||
|
||||
@@ -1278,6 +1278,40 @@ describe('host services', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('registers the typed Talk service with Main-owned relay ownership and no legacy direct IPC channel', () => {
|
||||
const source = readFileSync(join(process.cwd(), 'electron/main/ipc-handlers.ts'), 'utf8');
|
||||
const mainSource = readFileSync(join(process.cwd(), 'electron/main/index.ts'), 'utf8');
|
||||
const registerIpcHandlersSource = source.slice(
|
||||
source.indexOf('export function registerIpcHandlers('),
|
||||
source.indexOf('function registerTypedHostHandlers('),
|
||||
);
|
||||
|
||||
expect(source).toContain('const talkRelayOwnership = createTalkRelayOwnership();');
|
||||
expect(source).toContain('talk: createTalkApi(gatewayManager, talkRelayOwnership)');
|
||||
expect(source).not.toMatch(/ipcMain\.handle\(\s*['"]talk:/);
|
||||
expect(mainSource).toContain('forwardActiveTalkEvent(talkRelayOwnership, data');
|
||||
const returnIndex = registerIpcHandlersSource.indexOf('return talkRelayOwnership;');
|
||||
expect(returnIndex).toBeGreaterThan(-1);
|
||||
[
|
||||
'registerGatewayHandlers(gatewayManager);',
|
||||
'registerOpenClawHandlers();',
|
||||
'registerProviderHandlers(gatewayManager);',
|
||||
'registerShellHandlers();',
|
||||
'registerDialogHandlers();',
|
||||
'registerAppHandlers();',
|
||||
'registerSettingsHandlers(gatewayManager);',
|
||||
'registerUsageHandlers();',
|
||||
'registerCronHandlers(gatewayManager);',
|
||||
'registerWindowHandlers(mainWindow);',
|
||||
'registerWhatsAppHandlers(mainWindow);',
|
||||
'registerFilePreviewHandlers();',
|
||||
].forEach((registration) => {
|
||||
const registrationIndex = registerIpcHandlersSource.indexOf(registration);
|
||||
expect(registrationIndex).toBeGreaterThan(-1);
|
||||
expect(registrationIndex).toBeLessThan(returnIndex);
|
||||
});
|
||||
});
|
||||
|
||||
it('loads session summaries and transcript history through the typed sessions service', async () => {
|
||||
const sessionsDir = join(testOpenClawConfigDir, 'agents', 'main', 'sessions');
|
||||
mkdirSync(sessionsDir, { recursive: true });
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { TalkRelayEvent, TalkRelaySession } from '@shared/talk/types';
|
||||
import { createRealtimeTalkController } from '@/lib/talk/realtime-talk-controller';
|
||||
import { stopActiveRealtimeTalk, useRealtimeTalkStore } from '@/stores/realtime-talk';
|
||||
|
||||
const relay: TalkRelaySession = {
|
||||
relaySessionId: 'relay-1',
|
||||
provider: 'openai',
|
||||
transport: 'gateway-relay',
|
||||
audio: {
|
||||
inputEncoding: 'pcm16',
|
||||
inputSampleRateHz: 24_000,
|
||||
outputEncoding: 'pcm16',
|
||||
outputSampleRateHz: 24_000,
|
||||
},
|
||||
};
|
||||
|
||||
function setup(overrides: Partial<{
|
||||
sending: boolean;
|
||||
activeSessionKey: string | null;
|
||||
heartbeatOnly: boolean;
|
||||
startAudio: () => Promise<void>;
|
||||
relay: TalkRelaySession;
|
||||
}> = {}) {
|
||||
let eventListener: ((event: TalkRelayEvent) => void) | null = null;
|
||||
let gatewayStatusListener: ((status: { state: string; gatewayReady?: boolean }) => void) | null = null;
|
||||
let activeSessionKey = overrides.activeSessionKey ?? 'agent:main:session-1';
|
||||
const talk = {
|
||||
startRelay: vi.fn().mockResolvedValue(overrides.relay ?? relay),
|
||||
appendAudio: vi.fn().mockResolvedValue({ ok: true }),
|
||||
cancelOutput: vi.fn().mockResolvedValue({ ok: true }),
|
||||
acknowledgeMark: vi.fn().mockResolvedValue({ ok: true }),
|
||||
stopRelay: vi.fn().mockResolvedValue({ ok: true }),
|
||||
startAgentConsult: vi.fn().mockResolvedValue({ runId: 'run-1' }),
|
||||
submitToolResult: vi.fn().mockResolvedValue({ ok: true }),
|
||||
};
|
||||
const audio = {
|
||||
start: vi.fn(overrides.startAudio ?? (async () => {})),
|
||||
stop: vi.fn(),
|
||||
enqueueOutput: vi.fn().mockResolvedValue(undefined),
|
||||
clearOutput: vi.fn(),
|
||||
};
|
||||
const unsubscribe = vi.fn();
|
||||
const reload = vi.fn().mockResolvedValue(true);
|
||||
const controller = createRealtimeTalkController({
|
||||
talk,
|
||||
subscribeTalk: (listener) => {
|
||||
eventListener = listener;
|
||||
return unsubscribe;
|
||||
},
|
||||
subscribeGatewayStatus: (listener) => {
|
||||
gatewayStatusListener = listener;
|
||||
return vi.fn();
|
||||
},
|
||||
createAudio: () => audio,
|
||||
getAcpState: () => ({
|
||||
sending: overrides.sending ?? false,
|
||||
activeSessionKey,
|
||||
}),
|
||||
isHeartbeatSession: () => overrides.heartbeatOnly ?? false,
|
||||
reloadAcpSession: reload,
|
||||
});
|
||||
return {
|
||||
audio,
|
||||
controller,
|
||||
emit: (event: TalkRelayEvent) => eventListener?.(event),
|
||||
emitGatewayStatus: (status: { state: string; gatewayReady?: boolean }) => gatewayStatusListener?.(status),
|
||||
reload,
|
||||
setActiveSessionKey: (sessionKey: string | null) => { activeSessionKey = sessionKey; },
|
||||
talk,
|
||||
unsubscribe,
|
||||
};
|
||||
}
|
||||
|
||||
describe('realtime Talk controller', () => {
|
||||
beforeEach(() => {
|
||||
useRealtimeTalkStore.getState().reset();
|
||||
});
|
||||
|
||||
it('starts one selected relay and serializes local PCM input through the typed Talk API', async () => {
|
||||
const { audio, controller, talk } = setup();
|
||||
|
||||
await expect(controller.start({ sessionKey: 'agent:main:session-1' })).resolves.toBe(true);
|
||||
audio.start.mock.calls[0]?.[0](new Float32Array([0, 1, -1, 0]), 48_000);
|
||||
await vi.waitFor(() => expect(talk.appendAudio).toHaveBeenCalledWith(expect.objectContaining({
|
||||
relaySessionId: 'relay-1',
|
||||
audioBase64: 'AAAAgA==',
|
||||
})));
|
||||
|
||||
expect(useRealtimeTalkStore.getState()).toMatchObject({
|
||||
status: 'listening',
|
||||
relaySessionId: 'relay-1',
|
||||
sessionKey: 'agent:main:session-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects stale relay events and clears direct transcript state on terminal paths', async () => {
|
||||
const { controller, emit, talk } = setup();
|
||||
await controller.start({ sessionKey: 'agent:main:session-1' });
|
||||
|
||||
emit({ relaySessionId: 'stale', type: 'transcript', role: 'assistant', text: 'ignore', final: true });
|
||||
emit({ relaySessionId: 'relay-1', type: 'transcript', role: 'assistant', text: 'live', final: true });
|
||||
expect(useRealtimeTalkStore.getState().transcripts).toHaveLength(1);
|
||||
|
||||
emit({ relaySessionId: 'relay-1', type: 'close', reason: 'completed' });
|
||||
await vi.waitFor(() => expect(talk.stopRelay).toHaveBeenCalledWith({ relaySessionId: 'relay-1' }));
|
||||
expect(useRealtimeTalkStore.getState().transcripts).toEqual([]);
|
||||
});
|
||||
|
||||
it('cleans tracks, events, and relay ownership on session switch and gateway disconnect', async () => {
|
||||
const { audio, controller, emitGatewayStatus, setActiveSessionKey, talk, unsubscribe } = setup();
|
||||
await controller.start({ sessionKey: 'agent:main:session-1' });
|
||||
|
||||
await controller.handleSessionChange('agent:main:session-2');
|
||||
expect(audio.stop).toHaveBeenCalledOnce();
|
||||
expect(unsubscribe).toHaveBeenCalledOnce();
|
||||
expect(talk.stopRelay).toHaveBeenCalledWith({ relaySessionId: 'relay-1' });
|
||||
|
||||
setActiveSessionKey('agent:main:session-2');
|
||||
await controller.start({ sessionKey: 'agent:main:session-2' });
|
||||
emitGatewayStatus({ state: 'stopped' });
|
||||
await vi.waitFor(() => expect(talk.stopRelay).toHaveBeenCalledTimes(2));
|
||||
expect(useRealtimeTalkStore.getState().status).toBe('disconnected');
|
||||
});
|
||||
|
||||
it('keeps only the latest overlapping start and closes superseded relays before they can own audio', async () => {
|
||||
let resolveFirst!: (value: TalkRelaySession) => void;
|
||||
let resolveSecond!: (value: TalkRelaySession) => void;
|
||||
const firstRelay = new Promise<TalkRelaySession>((resolve) => { resolveFirst = resolve; });
|
||||
const secondRelay = new Promise<TalkRelaySession>((resolve) => { resolveSecond = resolve; });
|
||||
const { audio, controller, talk } = setup();
|
||||
talk.startRelay.mockReturnValueOnce(firstRelay).mockReturnValueOnce(secondRelay);
|
||||
|
||||
const first = controller.start({ sessionKey: 'agent:main:session-1' });
|
||||
const second = controller.start({ sessionKey: 'agent:main:session-1' });
|
||||
resolveFirst({ ...relay, relaySessionId: 'relay-old' });
|
||||
await vi.waitFor(() => expect(talk.stopRelay).toHaveBeenCalledWith({ relaySessionId: 'relay-old' }));
|
||||
resolveSecond({ ...relay, relaySessionId: 'relay-new' });
|
||||
|
||||
await expect(first).resolves.toBe(false);
|
||||
await expect(second).resolves.toBe(true);
|
||||
expect(audio.start).toHaveBeenCalledOnce();
|
||||
expect(useRealtimeTalkStore.getState().relaySessionId).toBe('relay-new');
|
||||
});
|
||||
|
||||
it('invalidates a pending start when ACP lifecycle cleanup runs before relay creation resolves', async () => {
|
||||
let resolveRelay!: (value: TalkRelaySession) => void;
|
||||
const pendingRelay = new Promise<TalkRelaySession>((resolve) => { resolveRelay = resolve; });
|
||||
const { audio, controller, talk } = setup();
|
||||
talk.startRelay.mockReturnValueOnce(pendingRelay);
|
||||
|
||||
const starting = controller.start({ sessionKey: 'agent:main:session-1' });
|
||||
await stopActiveRealtimeTalk();
|
||||
resolveRelay(relay);
|
||||
|
||||
await expect(starting).resolves.toBe(false);
|
||||
expect(talk.stopRelay).toHaveBeenCalledWith({ relaySessionId: 'relay-1' });
|
||||
expect(audio.start).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cancels once for barge-in and acknowledges marks after queued playback completes', async () => {
|
||||
let finishPlayback!: () => void;
|
||||
const playback = new Promise<void>((resolve) => { finishPlayback = resolve; });
|
||||
const { audio, controller, emit, talk } = setup();
|
||||
audio.enqueueOutput.mockReturnValue(playback);
|
||||
await controller.start({ sessionKey: 'agent:main:session-1' });
|
||||
|
||||
emit({ relaySessionId: 'relay-1', type: 'audio', audioBase64: 'AAD/fw==' });
|
||||
await vi.waitFor(() => expect(audio.enqueueOutput).toHaveBeenCalledOnce());
|
||||
audio.start.mock.calls[0]?.[0](new Float32Array([0.2]), 24_000);
|
||||
audio.start.mock.calls[0]?.[0](new Float32Array([0.3]), 24_000);
|
||||
expect(talk.cancelOutput).toHaveBeenCalledOnce();
|
||||
|
||||
emit({ relaySessionId: 'relay-1', type: 'mark', markName: 'end-turn' });
|
||||
expect(talk.acknowledgeMark).not.toHaveBeenCalled();
|
||||
finishPlayback();
|
||||
await vi.waitFor(() => expect(talk.acknowledgeMark).toHaveBeenCalledWith({
|
||||
relaySessionId: 'relay-1', markName: 'end-turn',
|
||||
}));
|
||||
});
|
||||
|
||||
it('drops queued output continuations after local barge-in', async () => {
|
||||
let finishFirst!: () => void;
|
||||
const firstPlayback = new Promise<void>((resolve) => { finishFirst = resolve; });
|
||||
const { audio, controller, emit, talk } = setup();
|
||||
audio.enqueueOutput.mockReturnValueOnce(firstPlayback).mockResolvedValue(undefined);
|
||||
await controller.start({ sessionKey: 'agent:main:session-1' });
|
||||
|
||||
emit({ relaySessionId: 'relay-1', type: 'audio', audioBase64: 'AAD/fw==' });
|
||||
emit({ relaySessionId: 'relay-1', type: 'audio', audioBase64: 'AAD/fw==' });
|
||||
await vi.waitFor(() => expect(audio.enqueueOutput).toHaveBeenCalledOnce());
|
||||
audio.start.mock.calls[0]?.[0](new Float32Array([0.2]), 24_000);
|
||||
expect(talk.cancelOutput).toHaveBeenCalledOnce();
|
||||
finishFirst();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(audio.enqueueOutput).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('fails safely for a pending ACP prompt, microphone denial, and unsupported audio', async () => {
|
||||
const pending = setup({ sending: true });
|
||||
await expect(pending.controller.start({ sessionKey: 'agent:main:session-1' })).resolves.toBe(false);
|
||||
expect(pending.talk.startRelay).not.toHaveBeenCalled();
|
||||
|
||||
const denied = setup({ startAudio: async () => { throw new Error('Permission denied'); } });
|
||||
await expect(denied.controller.start({ sessionKey: 'agent:main:session-1' })).resolves.toBe(false);
|
||||
expect(denied.talk.stopRelay).toHaveBeenCalledWith({ relaySessionId: 'relay-1' });
|
||||
|
||||
const unsupported = setup({ relay: { ...relay, audio: { ...relay.audio, inputEncoding: 'pcm16', outputEncoding: 'pcm16', inputSampleRateHz: 0 } } });
|
||||
await expect(unsupported.controller.start({ sessionKey: 'agent:main:session-1' })).resolves.toBe(false);
|
||||
expect(unsupported.talk.stopRelay).toHaveBeenCalledWith({ relaySessionId: 'relay-1' });
|
||||
});
|
||||
|
||||
it('requires the ACP-selected non-heartbeat session and never accepts the default fallback', async () => {
|
||||
const arbitrary = setup({ activeSessionKey: 'agent:main:selected' });
|
||||
await expect(arbitrary.controller.start({ sessionKey: 'agent:main:other' })).resolves.toBe(false);
|
||||
expect(arbitrary.talk.startRelay).not.toHaveBeenCalled();
|
||||
|
||||
const heartbeat = setup({ heartbeatOnly: true });
|
||||
await expect(heartbeat.controller.start({ sessionKey: 'agent:main:session-1' })).resolves.toBe(false);
|
||||
expect(heartbeat.talk.startRelay).not.toHaveBeenCalled();
|
||||
|
||||
const fallback = setup({ activeSessionKey: 'agent:main:main' });
|
||||
await expect(fallback.controller.start({ sessionKey: 'agent:main:main' })).resolves.toBe(false);
|
||||
expect(fallback.talk.startRelay).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps an error close event to explicit error state without a separate error message', async () => {
|
||||
const { controller, emit } = setup();
|
||||
await controller.start({ sessionKey: 'agent:main:session-1' });
|
||||
|
||||
emit({ relaySessionId: 'relay-1', type: 'close', reason: 'error' });
|
||||
await vi.waitFor(() => expect(useRealtimeTalkStore.getState().status).toBe('error'));
|
||||
expect(useRealtimeTalkStore.getState().error).toBeTruthy();
|
||||
});
|
||||
|
||||
it('starts Agent consults through Talk and reloads the same ACP session only after terminal completion', async () => {
|
||||
const { controller, emit, reload, talk } = setup();
|
||||
await controller.start({ sessionKey: 'agent:main:session-1' });
|
||||
|
||||
emit({ relaySessionId: 'relay-1', type: 'toolCall', callId: 'call-1', name: 'agent_consult', args: { prompt: 'review' } });
|
||||
await vi.waitFor(() => expect(talk.startAgentConsult).toHaveBeenCalledWith({
|
||||
relaySessionId: 'relay-1', sessionKey: 'agent:main:session-1', callId: 'call-1', args: { prompt: 'review' },
|
||||
}));
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
|
||||
emit({ relaySessionId: 'relay-1', type: 'toolResult', callId: 'call-1', final: true });
|
||||
await vi.waitFor(() => expect(reload).toHaveBeenCalledOnce());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { useRealtimeTalkStore } from '@/stores/realtime-talk';
|
||||
|
||||
describe('realtime Talk store', () => {
|
||||
beforeEach(() => {
|
||||
useRealtimeTalkStore.getState().reset();
|
||||
});
|
||||
|
||||
it('keeps direct partial and final transcripts only in ordered live memory', () => {
|
||||
const store = useRealtimeTalkStore.getState();
|
||||
store.begin('relay-1', 'agent:main:session-1');
|
||||
store.appendTranscript({ role: 'user', text: 'hel', final: false });
|
||||
store.appendTranscript({ role: 'user', text: 'hello', final: true });
|
||||
store.appendTranscript({ role: 'assistant', text: 'hi', final: true });
|
||||
|
||||
expect(useRealtimeTalkStore.getState().transcripts).toEqual([
|
||||
{ role: 'user', text: 'hello', final: true },
|
||||
{ role: 'assistant', text: 'hi', final: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('clears live transcript state on terminal, session reset, and app reset paths', () => {
|
||||
const store = useRealtimeTalkStore.getState();
|
||||
store.begin('relay-1', 'agent:main:session-1');
|
||||
store.appendTranscript({ role: 'assistant', text: 'temporary', final: true });
|
||||
store.finish('completed');
|
||||
|
||||
expect(useRealtimeTalkStore.getState()).toMatchObject({
|
||||
status: 'idle',
|
||||
relaySessionId: null,
|
||||
transcripts: [],
|
||||
});
|
||||
|
||||
store.begin('relay-2', 'agent:main:session-2');
|
||||
store.appendTranscript({ role: 'assistant', text: 'temporary again', final: true });
|
||||
store.reset();
|
||||
|
||||
expect(useRealtimeTalkStore.getState().transcripts).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
createTalkApi,
|
||||
createTalkRelayOwnership,
|
||||
forwardActiveTalkEvent,
|
||||
} from '@electron/services/talk-api';
|
||||
|
||||
const TALK_RPC_TIMEOUT_MS = 8_000;
|
||||
|
||||
describe('talk API', () => {
|
||||
it('uses only the expected Gateway RPCs with normalized relay parameters', async () => {
|
||||
const gatewayManager = {
|
||||
rpc: vi.fn()
|
||||
.mockResolvedValueOnce({ realtime: { ready: true, providers: [] } })
|
||||
.mockResolvedValueOnce({
|
||||
relaySessionId: 'relay-1',
|
||||
provider: 'openai',
|
||||
transport: 'gateway-relay',
|
||||
audio: {
|
||||
inputEncoding: 'pcm16',
|
||||
inputSampleRateHz: 24000,
|
||||
outputEncoding: 'pcm16',
|
||||
outputSampleRateHz: 24000,
|
||||
},
|
||||
})
|
||||
.mockResolvedValue({ ok: true }),
|
||||
};
|
||||
const talkApi = createTalkApi(gatewayManager as never);
|
||||
|
||||
await expect(talkApi.catalog()).resolves.toEqual({ realtime: { ready: true, providers: [] } });
|
||||
await expect(talkApi.startRelay({ sessionKey: ' agent:main:session-1 ' })).resolves.toMatchObject({
|
||||
relaySessionId: 'relay-1',
|
||||
});
|
||||
await talkApi.appendAudio({ relaySessionId: ' relay-1 ', audioBase64: 'AQI=', timestamp: 12.8 });
|
||||
await talkApi.cancelOutput({ relaySessionId: ' relay-1 ' });
|
||||
await talkApi.submitToolResult({ relaySessionId: ' relay-1 ', callId: ' call-1 ', result: { ok: true } });
|
||||
await talkApi.acknowledgeMark({ relaySessionId: ' relay-1 ', markName: ' mark-1 ' });
|
||||
await talkApi.stopRelay({ relaySessionId: ' relay-1 ' });
|
||||
await expect(talkApi.startAgentConsult({
|
||||
relaySessionId: ' relay-1 ',
|
||||
sessionKey: ' agent:main:session-1 ',
|
||||
callId: ' call-2 ',
|
||||
args: { prompt: 'hello' },
|
||||
})).resolves.toEqual({ ok: true });
|
||||
|
||||
expect(gatewayManager.rpc.mock.calls).toEqual([
|
||||
['talk.catalog', {}, TALK_RPC_TIMEOUT_MS],
|
||||
['talk.session.create', {
|
||||
sessionKey: 'agent:main:session-1',
|
||||
mode: 'realtime',
|
||||
transport: 'gateway-relay',
|
||||
brain: 'agent-consult',
|
||||
}, TALK_RPC_TIMEOUT_MS],
|
||||
['talk.session.appendAudio', { sessionId: 'relay-1', audioBase64: 'AQI=', timestamp: 12 }, TALK_RPC_TIMEOUT_MS],
|
||||
['talk.session.cancelOutput', { sessionId: 'relay-1' }, TALK_RPC_TIMEOUT_MS],
|
||||
['talk.session.submitToolResult', { sessionId: 'relay-1', callId: 'call-1', result: { ok: true } }, TALK_RPC_TIMEOUT_MS],
|
||||
['talk.session.acknowledgeMark', { sessionId: 'relay-1', markName: 'mark-1' }, TALK_RPC_TIMEOUT_MS],
|
||||
['talk.session.close', { sessionId: 'relay-1' }, TALK_RPC_TIMEOUT_MS],
|
||||
['talk.client.toolCall', {
|
||||
relaySessionId: 'relay-1',
|
||||
sessionKey: 'agent:main:session-1',
|
||||
callId: 'call-2',
|
||||
name: 'openclaw_agent_consult',
|
||||
args: { prompt: 'hello' },
|
||||
}, TALK_RPC_TIMEOUT_MS],
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects malformed relay requests before invoking the Gateway', async () => {
|
||||
const gatewayManager = { rpc: vi.fn() };
|
||||
const talkApi = createTalkApi(gatewayManager as never);
|
||||
|
||||
await expect(talkApi.startRelay({ sessionKey: ' ' })).rejects.toThrow('Invalid Talk session key');
|
||||
await expect(talkApi.appendAudio({ relaySessionId: ' ', audioBase64: 'AQI=' })).rejects.toThrow('Invalid Talk relay session id');
|
||||
await expect(talkApi.appendAudio({ relaySessionId: 'relay-1', audioBase64: 'not base64' })).rejects.toThrow('Invalid Talk PCM16 audio');
|
||||
await expect(talkApi.acknowledgeMark({ relaySessionId: 'relay-1', markName: ' ' })).rejects.toThrow('Invalid Talk mark name');
|
||||
await expect(talkApi.startAgentConsult({
|
||||
relaySessionId: 'relay-1',
|
||||
sessionKey: 'agent:main:session-1',
|
||||
callId: ' ',
|
||||
args: {},
|
||||
})).rejects.toThrow('Invalid Talk tool call id');
|
||||
|
||||
expect(gatewayManager.rpc).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not forward late events from stopped or replaced relays', async () => {
|
||||
const gatewayManager = {
|
||||
rpc: vi.fn()
|
||||
.mockResolvedValueOnce({ relaySessionId: 'relay-old' })
|
||||
.mockResolvedValueOnce({ ok: true })
|
||||
.mockResolvedValueOnce({ relaySessionId: 'relay-old' })
|
||||
.mockResolvedValueOnce({ relaySessionId: 'relay-new' }),
|
||||
};
|
||||
const ownership = createTalkRelayOwnership();
|
||||
const talkApi = createTalkApi(gatewayManager as never, ownership);
|
||||
const sendToRenderer = vi.fn();
|
||||
const oldEvent = {
|
||||
relaySessionId: 'relay-old',
|
||||
type: 'transcript' as const,
|
||||
role: 'assistant' as const,
|
||||
text: 'late',
|
||||
final: true,
|
||||
};
|
||||
const newEvent = { ...oldEvent, relaySessionId: 'relay-new' };
|
||||
|
||||
await talkApi.startRelay({ sessionKey: 'agent:main:session-1' });
|
||||
forwardActiveTalkEvent(ownership, oldEvent, sendToRenderer);
|
||||
await talkApi.stopRelay({ relaySessionId: 'relay-old' });
|
||||
forwardActiveTalkEvent(ownership, oldEvent, sendToRenderer);
|
||||
|
||||
await talkApi.startRelay({ sessionKey: 'agent:main:session-1' });
|
||||
await talkApi.startRelay({ sessionKey: 'agent:main:session-2' });
|
||||
forwardActiveTalkEvent(ownership, oldEvent, sendToRenderer);
|
||||
forwardActiveTalkEvent(ownership, newEvent, sendToRenderer);
|
||||
|
||||
expect(sendToRenderer).toHaveBeenCalledTimes(2);
|
||||
expect(sendToRenderer).toHaveBeenNthCalledWith(1, oldEvent);
|
||||
expect(sendToRenderer).toHaveBeenNthCalledWith(2, newEvent);
|
||||
});
|
||||
|
||||
it('keeps the latest relay active when concurrent starts resolve out of order', async () => {
|
||||
let resolveFirst!: (value: unknown) => void;
|
||||
let resolveSecond!: (value: unknown) => void;
|
||||
const firstResponse = new Promise<unknown>((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
});
|
||||
const secondResponse = new Promise<unknown>((resolve) => {
|
||||
resolveSecond = resolve;
|
||||
});
|
||||
const gatewayManager = {
|
||||
rpc: vi.fn()
|
||||
.mockReturnValueOnce(firstResponse)
|
||||
.mockReturnValueOnce(secondResponse)
|
||||
.mockResolvedValueOnce({ ok: true }),
|
||||
};
|
||||
const ownership = createTalkRelayOwnership();
|
||||
const talkApi = createTalkApi(gatewayManager as never, ownership);
|
||||
const sendToRenderer = vi.fn();
|
||||
const oldEvent = {
|
||||
relaySessionId: 'relay-old',
|
||||
type: 'transcript' as const,
|
||||
role: 'assistant' as const,
|
||||
text: 'late',
|
||||
final: true,
|
||||
};
|
||||
const newEvent = { ...oldEvent, relaySessionId: 'relay-new' };
|
||||
|
||||
const firstStart = talkApi.startRelay({ sessionKey: 'agent:main:session-1' });
|
||||
const secondStart = talkApi.startRelay({ sessionKey: 'agent:main:session-2' });
|
||||
resolveSecond({ relaySessionId: 'relay-new' });
|
||||
await secondStart;
|
||||
resolveFirst({ relaySessionId: 'relay-old' });
|
||||
await firstStart;
|
||||
|
||||
forwardActiveTalkEvent(ownership, oldEvent, sendToRenderer);
|
||||
forwardActiveTalkEvent(ownership, newEvent, sendToRenderer);
|
||||
|
||||
expect(sendToRenderer).toHaveBeenCalledTimes(1);
|
||||
expect(sendToRenderer).toHaveBeenCalledWith(newEvent);
|
||||
expect(gatewayManager.rpc).toHaveBeenLastCalledWith(
|
||||
'talk.session.close',
|
||||
{ sessionId: 'relay-old' },
|
||||
TALK_RPC_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
decodePcm16Base64,
|
||||
float32ToPcm16,
|
||||
pcm16ToBase64,
|
||||
pcm16ToFloat32,
|
||||
} from '@/lib/talk/audio';
|
||||
|
||||
describe('Talk audio codec', () => {
|
||||
it('converts Float32 microphone samples to clipped little-endian PCM16', () => {
|
||||
expect([...float32ToPcm16(new Float32Array([-1, -0.5, 0, 0.5, 1, 2]))])
|
||||
.toEqual([-32768, -16384, 0, 16383, 32767, 32767]);
|
||||
});
|
||||
|
||||
it('round-trips PCM16 bytes through base64', () => {
|
||||
const pcm = new Int16Array([-32768, 0, 32767]);
|
||||
|
||||
expect(pcm16ToBase64(pcm)).toBe('AIAAAP9/');
|
||||
});
|
||||
|
||||
it('rejects malformed and odd-length PCM16 base64 payloads', () => {
|
||||
expect(() => decodePcm16Base64('not base64')).toThrow('Invalid PCM16 base64 audio');
|
||||
expect(() => decodePcm16Base64('AQ==')).toThrow('Invalid PCM16 byte length');
|
||||
});
|
||||
|
||||
it('decodes little-endian PCM16 output for playback', () => {
|
||||
const decoded = pcm16ToFloat32(decodePcm16Base64('AIAAAP9/'));
|
||||
|
||||
expect([...decoded]).toEqual([-1, 0, 32767 / 32768]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user