Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
a8c18f0
feat(devui): add voice input support
fedeoliv Oct 8, 2026
6984335
feat(devui): add agent audio response support
fedeoliv Oct 9, 2026
5f0f97f
fix(devui): overlay audio scrubber on waveform
fedeoliv Oct 13, 2026
7d5230d
feat(devui): improve audio recording controls
fedeoliv Oct 13, 2026
3901bb5
docs(devui): simplify voice input guidance
fedeoliv Oct 13, 2026
625f7db
docs(devui): mention audio response playback
fedeoliv Oct 13, 2026
fd2af2b
refactor(devui): use OpenAI input audio type
fedeoliv Oct 13, 2026
630b023
style(devui): reduce audio formatting noise
fedeoliv Oct 16, 2026
c88942c
Merge branch 'main' into feature/devui-voice-input
fedeoliv Oct 16, 2026
9b27221
Refine audio content type validation
fedeoliv Oct 16, 2026
3fd46a3
fix(devui): address audio review edge cases
fedeoliv Oct 16, 2026
b9ff7c4
fix(devui): reduce audio backend impact
fedeoliv Oct 16, 2026
fc6068a
test(devui): reduce audio test coupling
fedeoliv Oct 16, 2026
4c7da43
fix(devui): preserve mapper type ignore comments
fedeoliv Oct 16, 2026
288b543
test(devui): fix conversation typing cast
fedeoliv Oct 16, 2026
c90ee2b
Merge branch 'main' into feature/devui-voice-input
fedeoliv Oct 19, 2026
d3cf637
refactor(devui): simplify audio handling and preserve recording UX
fedeoliv Oct 20, 2026
2a9bb3a
Merge branch 'main' into feature/devui-voice-input
fedeoliv Oct 20, 2026
107387c
fix(devui): align audio players with message text
fedeoliv Oct 20, 2026
8177cf3
chore(devui): merge main and rebuild UI assets
fedeoliv Oct 22, 2026
e934dc4
fix(devui): prefer embedded audio over file URLs
fedeoliv Oct 22, 2026
4a950b4
chore(devui): merge main and resolve UI bundle conflict
fedeoliv Oct 22, 2026
0e7748f
refactor(devui): remove redundant audio URL fallback
fedeoliv Oct 22, 2026
562cd5e
Merge branch 'main' into feature/devui-voice-input
fedeoliv Oct 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions python/packages/devui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,26 @@ response2 = client.responses.create(

**How it works:** DevUI automatically retrieves the conversation's message history from the stored thread and passes it to the agent. You don't need to manually manage message history - just provide the same `conversation` ID for follow-up requests.

### Voice Input and Audio Responses

DevUI chat supports microphone input in browsers that allow recording from `localhost` or HTTPS pages. Use the
microphone button to start recording, then stop to preview or cancel. You can also hold the microphone button to
record and release it to attach the recording. Recordings are converted to mono WAV, with defaults of 16 kHz,
60 seconds, and 10 MB. The recording bar shows elapsed time and animated live microphone levels.

DevUI sends the audio to the selected agent without transcribing it. The selected agent or model must be able to work
with audio. Uploaded files keep the existing attachment behavior and original format, including multiple audio
files such as WAV, MP3, M4A, and OGG. Support for those formats depends on the selected agent.

Recordings and agent audio share a waveform player with play/pause, seeking, and duration. Agent audio includes a
download action. Audio returned by the conversation-history API or a resumed response uses the same controls.
Playback uses the browser's audio element and does not depend on waveform decoding or downloading remote audio first.

Settings > General > Audio options groups capture limits, sample rate, preview, and browser processing hints under
Input. Auto-stop on silence optionally attaches click-to-record audio after a quiet pause and does not interrupt
push-to-talk. Output provides optional agent-audio autoplay, which is disabled by default and remains subject to
browser restrictions. Restored responses do not autoplay.

### OpenAI Proxy Mode

DevUI provides an **OpenAI Proxy** feature for testing OpenAI models directly through the interface without creating custom agents. Enable via Settings → OpenAI Proxy tab.
Expand Down
24 changes: 24 additions & 0 deletions python/packages/devui/agent_framework_devui/_audio.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Copyright (c) Microsoft. All rights reserved.

"""Conversion of Responses input_audio parts to framework audio content."""

from agent_framework import Content
from openai.types.responses import ResponseInputAudioParam
from pydantic import TypeAdapter, ValidationError

_RESPONSE_INPUT_AUDIO_ADAPTER = TypeAdapter(ResponseInputAudioParam)


def input_audio_to_content(value: object) -> Content:
"""Validate an input_audio part and preserve its audio data without transcription."""
try:
audio = _RESPONSE_INPUT_AUDIO_ADAPTER.validate_python(value)["input_audio"]
except ValidationError as exc:
raise ValueError("Invalid input_audio: expected audio data with wav or mp3 format") from exc
if not audio["data"]:
raise ValueError("Invalid input_audio: audio data must not be empty")

media_type = "audio/wav" if audio["format"] == "wav" else "audio/mpeg"
data = audio["data"]
uri = data if data.startswith("data:") else f"data:{media_type};base64,{data}"
return Content.from_uri(uri=uri, media_type=media_type, name=f"recording.{audio['format']}")
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
ResponseOutputRefusal,
)

from ._audio import input_audio_to_content
from ._utils import infer_media_type

_MODEL_OUTPUT_KIND_KEY = "model_output_kind"
Expand Down Expand Up @@ -577,6 +578,9 @@ def _to_agent_content(content: dict[str, Any]) -> Content | None:
)
return None

if content_type == "input_audio":
return input_audio_to_content(content)

if content_type == "input_file":
filename = content.get("filename")
filename = filename if isinstance(filename, str) else ""
Expand Down
7 changes: 7 additions & 0 deletions python/packages/devui/agent_framework_devui/_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

from agent_framework import Content, SupportsAgentRun, Workflow

from ._audio import input_audio_to_content
from ._conversations import ConversationStore, InMemoryConversationStore
from ._discovery import EntityDiscovery
from ._mapper import MessageMapper
Expand Down Expand Up @@ -751,6 +752,12 @@ def _convert_openai_input_to_chat_message(
)
)

elif content_type == "input_audio":
try:
contents.append(input_audio_to_content(content_dict))
except ValueError as exc:
raise InputConversionError(str(exc)) from exc

elif content_type == "function_approval_response":
# Handle function approval response with server-side validation
try:
Expand Down
49 changes: 33 additions & 16 deletions python/packages/devui/agent_framework_devui/_mapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
ResponseUsage,
ResponseWorkflowEventComplete,
)
from .models._openai_custom import MediaResponse, MediaResponseEvent

logger = logging.getLogger(__name__)
_MODEL_OUTPUT_KIND_KEY = "model_output_kind"
Expand All @@ -62,6 +63,7 @@
ResponseStreamEvent,
ResponseWorkflowEventComplete,
ResponseOutputItemAddedEvent,
CustomResponseOutputItemAddedEvent,
ResponseTraceEventComplete,
]

Expand Down Expand Up @@ -330,6 +332,7 @@ async def aggregate_to_response(self, events: Sequence[Any], request: AgentFrame

# Track complete message items that do not use the text-delta lifecycle.
complete_messages: dict[str, ResponseOutputMessage] = {}
custom_output_items: list[Any] = []

for event in events:
event_type = getattr(event, "type", None)
Expand Down Expand Up @@ -394,6 +397,8 @@ async def aggregate_to_response(self, events: Sequence[Any], request: AgentFrame
message_order.append(item.id)
if item.content:
complete_messages[item.id] = item
elif item_type in {"output_image", "output_file", "output_data"}:
custom_output_items.append(item)

# Handle function call arguments delta - accumulate arguments
elif event_type == "response.function_call_arguments.delta":
Expand Down Expand Up @@ -458,6 +463,8 @@ async def aggregate_to_response(self, events: Sequence[Any], request: AgentFrame
)
output_items.append(response_output_message)

output_items.extend(custom_output_items)

# If no output items at all, create an empty message
if not output_items and failed_response is None:
response_output_text = ResponseOutputText(type="output_text", text="", annotations=[])
Expand All @@ -476,7 +483,8 @@ async def aggregate_to_response(self, events: Sequence[Any], request: AgentFrame

usage = _response_usage(usage_data) if usage_data is not None else None

return OpenAIResponse(
response_type = MediaResponse if custom_output_items else OpenAIResponse
return response_type(
id=context["response_id"],
object="response",
created_at=datetime.now().timestamp(),
Expand All @@ -503,7 +511,7 @@ async def aggregate_to_response(self, events: Sequence[Any], request: AgentFrame

async def finalize_stream(
self, events: Sequence[Any], request: AgentFrameworkRequest
) -> ResponseCompletedEvent | ResponseFailedEvent:
) -> ResponseCompletedEvent | ResponseFailedEvent | MediaResponseEvent:
"""Create the single terminal event for a mapped response stream."""
final_response = await self.aggregate_to_response(events, request)

Expand All @@ -516,6 +524,13 @@ async def finalize_stream(
last_sequence_number = sequence_number
break

if isinstance(final_response, MediaResponse):
return MediaResponseEvent(
type="response.failed" if final_response.status == "failed" else "response.completed",
response=final_response,
sequence_number=last_sequence_number + 1,
)

if final_response.status == "failed":
return ResponseFailedEvent(
type="response.failed",
Expand Down Expand Up @@ -1622,7 +1637,7 @@ async def _map_usage_content(self, content: Any, context: dict[str, Any]) -> Non

async def _map_data_content(
self, content: Any, context: dict[str, Any]
) -> ResponseOutputItemAddedEvent | ResponseTraceEventComplete:
) -> CustomResponseOutputItemAddedEvent | ResponseTraceEventComplete:
"""Map DataContent to proper output item (image/file/data) or fallback to trace.

Maps Agent Framework DataContent to appropriate output types:
Expand All @@ -1631,7 +1646,9 @@ async def _map_data_content(
- Generic data → ResponseOutputData
- Unknown/debugging content → ResponseTraceEventComplete (fallback)
"""
mime_type = getattr(content, "mime_type", "application/octet-stream")
mime_type = (
getattr(content, "media_type", None) or getattr(content, "mime_type", None) or "application/octet-stream"
)
item_id = f"item_{uuid.uuid4().hex[:16]}"

# Extract data/uri
Expand Down Expand Up @@ -1663,9 +1680,9 @@ async def _map_data_content(
sequence_number=self._next_sequence(context),
)

return ResponseOutputItemAddedEvent(
return CustomResponseOutputItemAddedEvent(
type="response.output_item.added",
item=ResponseOutputImage( # type: ignore[arg-type]
item=ResponseOutputImage(
id=item_id,
type="output_image",
image_url=image_url,
Expand Down Expand Up @@ -1720,9 +1737,9 @@ async def _map_data_content(
sequence_number=self._next_sequence(context),
)

return ResponseOutputItemAddedEvent(
return CustomResponseOutputItemAddedEvent(
type="response.output_item.added",
item=ResponseOutputFile( # type: ignore[arg-type]
item=ResponseOutputFile(
id=item_id,
type="output_file",
filename=filename,
Expand Down Expand Up @@ -1750,9 +1767,9 @@ async def _map_data_content(
else:
data_str = str(data_value)

return ResponseOutputItemAddedEvent(
return CustomResponseOutputItemAddedEvent(
type="response.output_item.added",
item=ResponseOutputData( # type: ignore[arg-type]
item=ResponseOutputData(
id=item_id,
type="output_data",
data=data_str,
Expand All @@ -1765,15 +1782,15 @@ async def _map_data_content(

async def _map_uri_content(
self, content: Any, context: dict[str, Any]
) -> ResponseOutputItemAddedEvent | ResponseTraceEventComplete:
) -> CustomResponseOutputItemAddedEvent | ResponseTraceEventComplete:
"""Map UriContent to proper output item (image/file) based on MIME type.

UriContent has a URI and MIME type, so we can create appropriate output items:
- Images → ResponseOutputImage
- Common files → ResponseOutputFile
- Other URIs → ResponseTraceEventComplete (fallback for debugging)
"""
mime_type = getattr(content, "mime_type", "text/plain")
mime_type = getattr(content, "media_type", None) or getattr(content, "mime_type", None) or "text/plain"
uri = getattr(content, "uri", "")
item_id = f"item_{uuid.uuid4().hex[:16]}"

Expand All @@ -1790,9 +1807,9 @@ async def _map_uri_content(

# Handle images
if mime_type.startswith("image/"):
return ResponseOutputItemAddedEvent(
return CustomResponseOutputItemAddedEvent(
type="response.output_item.added",
item=ResponseOutputImage( # type: ignore[arg-type]
item=ResponseOutputImage(
id=item_id,
type="output_image",
image_url=uri,
Expand All @@ -1819,9 +1836,9 @@ async def _map_uri_content(
# Extract filename from URI or use generic name
filename = uri.split("/")[-1] if "/" in uri else f"output.{mime_type.split('/')[-1]}"

return ResponseOutputItemAddedEvent(
return CustomResponseOutputItemAddedEvent(
type="response.output_item.added",
item=ResponseOutputFile( # type: ignore[arg-type]
item=ResponseOutputFile(
id=item_id,
type="output_file",
filename=filename,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from dataclasses import dataclass
from typing import Any, Literal

from openai.types.responses import Response, ResponseOutputItem
from pydantic import BaseModel, ConfigDict

# Custom Agent Framework OpenAI event types for structured data
Expand Down Expand Up @@ -56,8 +57,8 @@ class ExecutorActionItem(BaseModel):
class CustomResponseOutputItemAddedEvent(BaseModel):
"""Custom version of ResponseOutputItemAddedEvent that accepts any item type.

This allows us to emit executor action items while maintaining the same
event structure as OpenAI's standard.
This supports executor actions and media output items while maintaining
the same event structure as OpenAI's standard.
"""

type: Literal["response.output_item.added"] = "response.output_item.added"
Expand Down Expand Up @@ -274,6 +275,21 @@ class ResponseOutputData(BaseModel):
"""Optional description of the data."""


class MediaResponse(Response):
"""Responses envelope with validated DevUI media output extensions."""

# The SDK's output union excludes the media items already supported by DevUI.
output: list[ResponseOutputItem | ResponseOutputImage | ResponseOutputFile | ResponseOutputData] # pyright: ignore[reportIncompatibleVariableOverride]


class MediaResponseEvent(BaseModel):
"""Terminal event retaining the media response's extended serialization schema."""

type: Literal["response.completed", "response.failed"]
sequence_number: int
response: MediaResponse


# Agent Framework extension fields
class AgentFrameworkExtraBody(BaseModel):
"""Agent Framework specific routing fields for OpenAI requests."""
Expand Down

Large diffs are not rendered by default.

56 changes: 28 additions & 28 deletions python/packages/devui/agent_framework_devui/ui/assets/index.js

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions python/packages/devui/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"build": "tsc -b && vite build",
"lint": "eslint .",
"test:tracing": "node --test test/trace-polling.test.mjs",
"test:audio": "node --test test/response-media.test.mjs",
"preview": "vite preview"
},
"dependencies": {
Expand Down
Loading
Loading