This commit is contained in:
Timothy Jaeryang Baek committed 2026-10-14 18:22:33 +04:00
1 parent 323139cdf5
commit a70e96525d
5 files changed
+58 -11

No files matched your search

+16 -4
View File
@@ -76,9 +76,15 @@ class CallProtocol:
def observe(self, event):
kind = event.get('type')
if kind == 'conversation.item.input_audio_transcription.completed':
if event.get('transcript', '').strip():
if kind in {
'conversation.item.input_audio_transcription.completed',
'conversation.item.input_audio_transcription.failed',
}:
if kind.endswith('.completed') and event.get('transcript', '').strip():
self.transcripts.add(event['item_id'])
else:
# Rejected turns must leave the provider's history as well as the chat.
return {'type': 'conversation.item.delete', 'item_id': event['item_id']}
elif kind == 'response.created':
self.responses.add(event['response']['id'])
self.response_metadata[event['response']['id']] = event['response'].get('metadata') or {}
@@ -452,12 +458,14 @@ async def realtime_call(ws: WebSocket):
'audio': {
'input': {
'format': {'type': 'audio/pcm', 'rate': 24000},
'noise_reduction': {'type': 'near_field'},
'transcription': {'model': config['audio.realtime.transcription_model']},
# Wait for the finalized transcript before requesting a response. This gives
# every function call an unambiguous input_item_id, even during barge-in.
'turn_detection': {
'type': 'server_vad',
'interrupt_response': True,
# The client pauses playback until speech is confirmed.
'interrupt_response': False,
'create_response': False,
},
},
@@ -502,7 +510,11 @@ async def realtime_call(ws: WebSocket):
if kind == 'error':
# Provider error text can include prompts or credentials.
raise ValueError('Voice provider rejected a request')
protocol.observe(event)
command = protocol.observe(event)
if command:
# Clean up before the client can request another response.
async with asyncio.timeout(5):
await upstream.send_json(command)
if kind.startswith(('response.', 'conversation.item.', 'input_audio_buffer.')):
async with asyncio.timeout(5):
await ws.send_json(event)
+11
View File
@@ -505,6 +505,17 @@ async def execute_automation(app, automation: AutomationModel) -> None:
'automation_id': automation.id,
'background_tasks': {},
}
# Match the chat UI's memory preference, including inherited defaults.
ui_settings = (user.settings.ui or {}) if user.settings else {}
memory = ui_settings.get('memory')
if memory is None:
defaults = await Config.get('ui.default_interface_settings') or {}
memory = defaults.get('memory')
if memory is None:
memory = await Config.get('memories.enable')
if memory:
form_data.setdefault('features', {})['memory'] = True
# Call the full chat completion pipeline (same as POST /api/chat/completions).
# The handler reference is stored on app.state to avoid circular imports.
request = _build_request(app, token=token)
@@ -1,5 +1,9 @@
<script lang="ts">
import { getContext, onMount } from 'svelte';
import dayjs, { getDayjsLocale } from '$lib/dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(relativeTime);
const i18n = getContext<typeof import('$lib/i18n').default>('i18n');
@@ -30,9 +34,14 @@
let localTime = '';
$: lastSeen = user?.last_active_at
? dayjs(user.last_active_at * 1000)
.locale(getDayjsLocale($i18n.language))
.from(now)
: '';
$: lastSeenTimestamp = user?.last_active_at
? new Date(user.last_active_at * 1000).toLocaleString($i18n.language, {
dateStyle: 'medium',
timeStyle: 'short'
timeStyle: 'long'
})
: '';
@@ -166,7 +175,11 @@
<span class="sr-only">{$i18n.t('Last seen')}</span>
</Tooltip>
</dt>
<dd>{lastSeen}</dd>
<dd>
<Tooltip content={lastSeenTimestamp}>
<span>{lastSeen}</span>
</Tooltip>
</dd>
</div>
{/if}
</dl>
+4 -1
View File
@@ -10,6 +10,7 @@ class RealtimeAudioProcessor extends AudioWorkletProcessor {
this.queued = 0;
this.received = 0;
this.playing = false;
this.paused = false;
this.rendered = new Map();
this.ended = new Set();
this.ticks = 0;
@@ -22,6 +23,8 @@ class RealtimeAudioProcessor extends AudioWorkletProcessor {
if (data.type === 'capture') {
this.enabled = data.enabled;
this.captureLength = 0;
} else if (data.type === 'pause') {
this.paused = data.paused;
} else if (data.type === 'audio') {
// Speech can arrive faster than playback; release each chunk after it plays.
this.received += data.samples.length;
@@ -68,7 +71,7 @@ class RealtimeAudioProcessor extends AudioWorkletProcessor {
if (!this.playing && (this.queued >= 1920 || this.ended.has(this.queue[0]?.response_id))) {
this.playing = true;
}
if (this.playing) {
if (this.playing && !this.paused) {
let offset = 0;
while (offset < output.length && this.queue.length) {
const chunk = this.queue[0];
+12 -4
View File
@@ -350,9 +350,11 @@ export class RealtimeCall {
this.syncContext();
this.audio?.port.postMessage({ type: 'capture', enabled: !this.muted });
} else if (type === 'input_audio_buffer.speech_started') {
if (this.muted) return;
this.receivingSpeech = event.item_id;
this.userSpeaking = !this.muted;
this.stopSpeaking();
// VAD is provisional: retain the audio so a noise-only turn can resume it.
this.audio?.port.postMessage({ type: 'pause', paused: true });
} else if (type === 'input_audio_buffer.speech_stopped') {
if (this.receivingSpeech === event.item_id) this.userSpeaking = false;
} else if (
@@ -367,6 +369,8 @@ export class RealtimeCall {
if (this.inputs.has(event.item_id)) return;
const failed = type === 'conversation.item.input_audio_transcription.failed';
const text = failed ? '' : event.transcript?.trim();
if (text) this.stopSpeaking();
if (!this.receivingSpeech) this.audio?.port.postMessage({ type: 'pause', paused: false });
if (!text) {
// Empty VAD segments are not requests and must not produce assistant replies.
if (failed)
@@ -453,8 +457,7 @@ export class RealtimeCall {
if (this.animationCalls.has(event.item.call_id)) return;
this.animationCalls.add(event.item.call_id);
let status = 'unavailable';
if (!response || this.interrupted.has(event.response_id) || this.receivingSpeech)
status = 'cancelled';
if (!response || this.interrupted.has(event.response_id)) status = 'cancelled';
else {
try {
const args = JSON.parse(event.item.arguments);
@@ -713,12 +716,17 @@ export class RealtimeCall {
if (this.muted) {
this.inputLevel = 0;
this.userSpeaking = false;
this.receivingSpeech = '';
this.audio?.port.postMessage({ type: 'pause', paused: false });
}
this.stream?.getAudioTracks().forEach((track) => {
track.enabled = !this.muted;
});
this.audio?.port.postMessage({ type: 'capture', enabled: this.connected && !this.muted });
if (this.muted) this.send({ type: 'input_audio_buffer.clear' });
if (this.muted) {
this.send({ type: 'input_audio_buffer.clear' });
this.flush();
}
this.options.change();
}