* Keep what a voice call said on its messages, and read Open WebUI's realtime call settings
Open WebUI 0.12 stores a realtime call's spoken transcripts in each
message's meta.voice and replays them to the chat model. A message
Conduit wrote again from its own state carried no meta, and the server
replaces each message whole, so the next push would have erased them.
Rows rewritten locally now keep the meta stored on them, the voice record
is read into the message's metadata, and the echo writes it back where
Open WebUI reads it. Another device's chat:message:voice refreshes the
chat.
Direct chats replay what a call spoke the way Open WebUI does: a reply
the voice gave by itself becomes its transcript, labeled as historical
voice speech, and what was spoken over a chat answer follows that answer.
The config's audio.realtime block and the chat.call permission are read,
ready for gating realtime calls; unreadable permissions count as unknown.
* Speak and listen through each chat's own server, including Hermes and Direct
The Server engines for speech to text and text to speech only worked
with an Open WebUI server, and in a Hermes or Direct chat they quietly
used whichever Open WebUI server was signed in. Server speech now
follows the selected model's backend: an Open WebUI model uses its
server, a Hermes chat on a Desktop Gateway uses the gateway's audio
relay, and a Direct or Apple chat uses a new Voice provider. A chat
whose backend has none uses the device instead; audio never goes to a
server the chat does not belong to.
Hermes speech goes through /api/audio/transcribe and /api/audio/speak
for the connection's profile, with the providers and keys configured on
the gateway; the voice-config route, which hands out provider keys, is
never called.
The Voice provider is one Direct connection that speaks the OpenAI API,
chosen in Audio settings with its transcription model, speech model and
voice. Requests go to its audio/transcriptions and audio/speech with
the connection's own key, headers, api-version and certificates. Picking
OpenAI fills in the models Open WebUI defaults to. The page is also in
the native Settings sheet on iOS.
The server voice picker shows only for Open WebUI, which lists voices;
Hermes and the Voice provider speak with their own.
* Talk to a realtime voice in calls, ready for Open WebUI and Direct
Open WebUI 0.12 can run calls through an OpenAI realtime voice that
answers small talk itself and hands real requests to the chat's model.
This adds the call side of that, shared by Open WebUI and Direct chats;
the audio engine it needs comes next, so calls stay as they are until
then.
The engine follows Open WebUI's web client: one request to the voice at
a time, made only while nobody is speaking; the chat's latest state sent
before each; speaking over the voice cancels its reply and tells it how
much was heard; a request the voice delegates runs as an ordinary chat
turn, and its outcome goes back to be spoken. Open WebUI's server
bridge carries a call for its chats. A Direct or Apple chat talks to the
Voice provider's Realtime API instead, with the bridge's rules checked
on the device, so one engine runs both; the device's bridge is tested
against the server's own rules, and the engine's tests hold every
command to them.
A delegated request is sent like a typed message, with the user's words
marked as spoken. A reply the voice gives itself is stored through the
same send as a finished answer under the voice model, and no model
runs; Open WebUI replays it to the chat model as past speech. What the
voice says about a delegated answer is merged into that answer's
meta.voice. Words nothing answered stay in the call's captions.
The call controller runs either engine: a call with a realtime voice
keeps CallKit, the audio session, background work, mute, speaker and
the call panel as before. Whether a call has one is decided when it
starts, and it falls back to a Standard call whenever anything is
unknown: Standard chosen in settings, a temporary chat, a server without
realtime calls, an account that may not call or whose permissions could
not be read, a sign-in with an API key, or a Voice provider without a
realtime model, voice and transcription model.
Direct connections now share their key, header and certificate setup
between HTTP and WebSocket, and Open WebUI's socket headers are built in
one place, as is the WebSocket that never follows redirects.
* Translate the Voice provider settings into Polish
Polish carries every English key, so the Voice provider's strings need
their Polish copies; without them polish_localization_test fails.
* Talk with a realtime voice in calls on iOS and Android
Calls in Direct and Apple chats with a Voice provider, and in Open WebUI
chats on a server with realtime calls, now talk with a realtime voice:
both sides can speak at once, and speaking over the voice stops it.
The voice needs the microphone and the speaker on one echo-cancelled
stream, so it does not hear itself. On iOS one audio engine runs with
voice processing on: the microphone is converted to 24 kHz frames, and
the voice plays from a queue that works like Open WebUI's web worklet,
starting once 80 ms are buffered and reporting how much of each part
was played when the user cuts in. It restarts after a route change. On
Android the microphone records as voice communication with the
platform's echo canceller, noise suppressor and gain control, and the
voice plays on the same audio session; what was heard comes from the
track's play position. A microphone that only offers 48 kHz is brought
down to 24 kHz. If the audio stops mid-call, the call ends and says so.
Audio settings gain Calls: Realtime, the default, or Standard, which
keeps every call turn by turn. The Voice provider takes a realtime
model, voice and optional instructions, prefilled for OpenAI. Both are
in the native Settings sheet on iOS too.
* Tell the user what a realtime call could not do, and mark what its voice said
A realtime call's problems that do not end it now show as messages in
the user's language: words that could not be made out, a reply cut
short, and a part of the call that could not be saved to the chat.
A reply the voice gave by itself shows its voice model with a waveform
and "spoken", even when the answer above came from the same model, and
offers no Regenerate: there is no chat model answer to run again.
Opening another chat, or none, ends a realtime call: its voice talks
about the chat it started in, and a request it handed over is answered
there. A call started without a chat follows the chat its first turn
creates, and that chat's server id.
* Talk with Hermes's GPT-Live voice in calls on a Desktop Gateway
A Hermes profile that runs voice calls through GPT-Live, and has an
OpenAI key for it, now gets realtime calls in Conduit too. The gateway
keeps the key: Conduit makes a WebRTC offer, Hermes opens the session
with OpenAI and returns the answer, and the voice's audio and events then
flow between the phone and OpenAI. Profiles in the chained voice mode, a
gateway that cannot say, and Responses API connections keep Standard
calls.
The call follows Hermes's own desktop client. GPT-Live takes its own
turns; a request it delegates carries no text, so Conduit sends the
user's latest words as an ordinary Hermes turn, marked as coming from
voice-live, with the spoken conversation of the last five minutes as
context only the model reads. While Hermes works the voice is told so
quietly; an approval waiting in the chat is mentioned once; the answer
is handed back in sentence-sized pieces for the voice to say. A newer
request stops the one still running, mute reaches the session, and
ending a call closes the session before the connection.
The WebRTC call comes from flutter_webrtc, behind the same port as the
other call audio, so the call controller, CallKit, background work and
the call panel are shared with every other call.
* Translate realtime calls and the Voice provider into every language
The Calls setting, the Voice provider page, the call's notices and the
"spoken" label were in English and Polish only, and the Localization check
requires every key in every language. They are now in all of them.
Every language also still said Server speech always goes to Open WebUI.
It now follows the chat's own server, so the descriptions name Open WebUI,
Hermes, or the Voice provider for Direct chats, as the English does.
* Keep the Voice provider page and the iOS call audio within the app's rules
The Voice provider page imported the Material and Cupertino libraries
directly, which the platform UI check allows only behind the shared
facade; it now takes its icons from the shared vocabulary. The package
boundary check also lists the new iOS call audio bridge, which reaches its
host only through ConduitBridgeHost.
* Fix what review found in realtime calls
- A call writes only to its own chat. What it said could land in the chat
the user opened as the call ended, and a call started without a chat
adopted any chat opened next. It now follows only the chat its own send
creates and that chat's server id, writes nothing elsewhere, and merges a
delegated answer's transcript into the call's chat even after a switch.
- A call that starts a new chat no longer seeds GPT-Live with the previous
chat's messages, or names that chat to Open WebUI.
- Hanging up while a call is starting leaves no microphone, WebRTC peer or
socket open. A WebSocket handshake that times out is stopped, and a
socket that opens after it is closed.
- GPT-Live's microphone goes quiet before the session is asked to close,
and an answer handed over as the call ends goes on in the chat unfollowed.
- Muting while speaking no longer holds a finished answer back until the
user speaks again.
- Small talk held behind a delegated answer is saved after that answer
when the call ends, so the answer stays last while it runs and can still
be stopped. If it is still running a few seconds later, the small talk
stays in the captions.
- A delegated answer whose send failed after it was placed ends as failed
instead of working forever. Hermes approvals and questions now count as
waiting for the user, so the voice says to answer them in the chat.
- GPT-Live's request is the user's latest words from the last five
minutes, as in Hermes's own client; words from long before no longer
join it.
- Read-aloud and dictation keep the server they started with, so text and
recordings never go to another chat's server when the model changes.
- A voice reply's "spoken" label appears when its transcript arrives after
the message, the native Voice provider choice is kept while connections
reload, the call mode is saved on its own key, a Direct voice turn
releases its database lease if saving it fails, and ending the call
controller ends its realtime voice.
* Fix what review found in the realtime call audio on iOS and Android
- Android: a piece of the voice's speech taken just before the user cut in
could still play, and count as heard. It is now taken with its clear
generation, so a clear right after drops it. A stopped call's capture
and playback threads stop with it, and an old thread sends nothing to
the next call; the recorder is stopped before its thread is awaited, so
a blocked read returns first.
- iOS: after a Bluetooth or headset route change, the microphone tap and
converter are rebuilt for the new route's format. The playback queue no
longer builds a string on the audio thread for every render.
* Fix what the second review found in realtime calls
- The first request in a new chat no longer ends the call: the call's own
send counts as creating its chat until it is done, as the chat can open
only after the answer is placed.
- A chat opened while the call connects ends it, and the voice never reads
a chat other than the call's own.
- A reply cut off by hanging up waits behind a delegated answer still
running, like the small talk held before it.
- A GPT-Live request replaced while the one before it was stopping is
dropped instead of being sent to the chat.
- Resetting settings clears the Voice provider for good; it came back on
the next launch.
- An earlier version of a voice reply is not labeled as spoken: versions
keep no transcript to tell.
- The call's chat host closes even when the voice's ending fails, and
chunkForCommentary takes only a positive limit.
* Follow only the chat a realtime call's own send created
A call started without a chat followed any chat that opened while its
first send was running, so a chat the user opened during that wait became
the call's: the voice read it, and later requests went there. The call now
remembers the messages its sends placed and follows a new chat only if it
holds them, which every send path's new chat does when it opens.
* Hand GPT-Live's newest request to the chat only once older answers stop
A request replaced while the one before it was still stopping skipped the
wait: the newest went to the chat while the first answer still ran, and
the chat put it off as busy. Stopping replaced requests now runs as one
chain, and a new request waits for all of it, including stops added while
it waited.
* Leave answers handed over as a realtime call ends running in the chat
- A request handed to the chat as the call ended was stopped by the bridge
engine, though the chat should go on answering it. Both engines now stop
only following such an answer, through a new DelegatedTurn.close that
leaves the answer running, and its listener no longer outlives the call.
- A GPT-Live request still being handed to the chat when a newer one came
is now stopped once the chat has it, before the newer request goes, so
the chat never takes two at once.
* Name Apple chats where Server speech says it uses the Voice provider
The Voice provider page says Direct and Apple chats use it, but the
Server speech descriptions named only Direct chats. Both now say Direct
and Apple chats, in every language, worded as each language's Voice
provider description already words it.
* Keep realtime calls working alongside enforced temporary chats
Open WebUI 0.12 support (#843) made a new chat start temporary when the
server enforces it, and reading that now reaches the signed-in account and
its permissions. Three things follow for realtime calls:
- A call that starts a new chat stays Standard when the server will keep
that chat temporary, as it already did for a temporary chat that is open.
- A realtime route that cannot be read keeps the call Standard instead of
failing it, like anything else the route cannot tell.
- The Hermes route tests say the server enforces nothing, as they never
sign in to Open WebUI.
* End a realtime call at once when it is hung up while connecting
- Hanging up while a realtime voice was still connecting waited for the
start queued ahead of the stop, so the microphone and connection could
stay up until the connection was made or timed out. The stop now ends a
connecting voice right away, and the start it cuts short counts as
cancelled, not failed.
- A voice reply's "spoken" label kept its old language after the app's
language changed: the cached header now keys on the label as shown.
* Let every end of a realtime call wait for its one ending
A hang-up while the voice connects ends it at once, and the teardown that
follows ends it again. The second end returned as soon as the voice was
marked ended, so teardown could turn off the audio session before the
voice had stopped, and an error stopping it went unreported. Both engines
now keep their ending: every end waits for the same one, and teardown
reports what it throws.
* End a connecting realtime call at once when the system call UI hangs up
Hanging up from the CallKit screen queued the stop without the immediate
end a hang-up in the app gets, so a realtime voice still connecting kept
its microphone and connection until it connected or timed out, and could
connect after the call had ended. Both hang-ups now share that immediate
end, and the system's own end of the call is still not repeated.
* Fix what Greptile's full review found in realtime calls
- A chat opened while a call was still setting up kept the call going with
the voice made for the chat the user left. The call now notes its chat
when it starts, and a different chat open by the time it connects
cancels it.
- Stopping a request the call replaces now waits for the chat's stop to
settle, so the stop's late effects cannot reach the new request.
- An answer whose send failed after it was placed is marked failed in the
chat, the way a typed send's is, instead of staying shown as running and
keeping the chat busy.
- Small talk waits while the chat is answering, whoever asked; it used to
wait only for answers the call had delegated, so a typed answer could
stop being the chat's last message and could no longer be stopped.
- Pause holds the voice's answers until the call resumes, instead of only
muting the microphone while finished answers went on being spoken.
- Speech to text set to Server uses the device in a chat whose backend
cannot transcribe, as the Voice provider promised, instead of hiding
voice input.
- Choosing the native Voice provider's connection, or editing its fields,
now redraws that page, which iOS redraws only when it is the page the
update is for.
* Hold a realtime call to the chat it was asked for, and unpause on unmute
- The chat a call is for is now noted when the call is asked for, before
the setup that waits for the microphone and the route. A chat opened
during that wait cancels the call instead of becoming its chat.
- Unmuting a paused realtime call, from the app or CallKit, now resumes it
too; the voice stayed paused and kept its answers back while the call
showed as live.
* Keep held small talk from silencing requests, and pause notices honest
- Small talk said while a typed answer runs waited on the call's own
writes, which a request then waited behind for minutes, so the caller
heard nothing instead of being told the chat was busy. It is now held
apart: a request meanwhile hears at once that the chat is busy, and one
made after the answer comes after the held words.
- A GPT-Live request that waited for approval while the call was paused,
then finished, no longer has the voice say on resume that it still
waits; its answer is still said.
- The native Voice provider page is patched alone and at once when it
opens, rather than after the speech voices it does not show have loaded,
so the patch cannot land over text the user is typing.
* Leave GPT-Live answers alone as a call closes, and drop replaced notices
- A request the voice handed over while the call was closing replaced the
answer still running, which stopped it although an ended call leaves its
answer to go on in the chat. Such a request now replaces nothing.
- A request replaced while the call was paused had its held approval
notice said on resume, as if it still waited. Replacing a request now
drops what was held about it.
The saved-connections list, the connection editor and the sidebar switcher used Material and Cupertino controls directly, which the platform UI lint rejects. The editor's loading and switching spinners, the list's add and edit buttons, and the switcher's tap target now come from the seam's progress and button facades, and the switcher draws its initials avatar with plain widgets. Sizes, keys, tooltips and accessibility labels stay the same; on Android the switcher row is 8 points taller when it can switch, because the button pads its tap target. The editor no longer imports material_ui, so its allow-list entry is removed.
The validator walked and re-read the whole tree for every key, which took minutes in CI. Whole-identifier matches settle most keys; the rest fall back to the same substring search, so the report is unchanged.
* Move feature logic into conduit_core behind small host seams
Logic that lived inside Flutter widgets and services moves to
conduit_core, where it is tested without Flutter:
- chat message delete and edit-resend, and the completion runner seam
that breaks the sync engine's dependency cycle;
- WebView cookie identity, error categorisation and retry policy, SVG
sniffing and WebView height parsing;
- the terminal controllers (behind a TerminalScreen seam) and PDF link
rules;
- the voice-mode controller, behind voice-mode host ports bound in
lib/main.dart;
- release-notes decisions, banner state and catalog loading;
- the MCP content sheet's load session and the composer's MCP insertion
rule;
- folder tree ordering and guide geometry, model icon URL resolution,
and account profile form rules with a pure-Dart initials avatar.
lib/ keeps re-exports where importers remain. Fixes carried along:
channel thread replies stay out of the channel list, an edited channel
message keeps its sender, reactions and reply count, the terminal sends
its new grid size on resize, a PDF viewer keeps its on-screen pages
cached, and the voice call panel's minimize and expand buttons are
localized.
* Fix review findings in the moved feature logic
- A selection with one null or negative end is no selection, so MCP content
appends instead of replacing text after the caret.
- The silent voice-mode speech engine reports completion when a turn
finishes, so a host without speech still resumes listening.
- Thread reply edits keep the sender, reactions and reply counts, as channel
message edits do.
- The fullscreen PDF viewer keeps the pages it has built when the bitmap
cache trims.
- A failed multi-message delete restores only the messages the server did
not delete.
- Release notes leave the banner alone on a relaunch once the caller is gone.
- Terminal download names keep characters that cannot steer a path.
- Model icon metadata of an unexpected shape resolves to no icon.
- The generic error text no longer repeats the exception text.
- Tests for each, plus the resend no-op and failure paths.
* Remove direction controls from terminal download names
A bidirectional override such as U+202E could make an executable read as a
different extension in the save dialog.
* Move the chat library and Flutter-free app logic into conduit_core
The chat providers, services, models and utilities under lib/features/chat
never needed Flutter; they move to packages/conduit_core with the rest of
the app's Flutter-free logic (channels, navigation, notes, prompts,
terminal and workspace models and providers, route redirect policy,
persistence and sync helpers). Their tests move with them and run as plain
Dart tests in the package.
The move needs three small ports so the core stays free of plugins:
location (geolocator), wakelock (wakelock_plus) and route navigation. The
app installs its adapters at startup and in the test config, so behaviour
is unchanged. The route redirect keeps main's accountless-backend rule.
* Test the core redirect policy, route remap port and UI request default
- resolveRouteRedirect runs as a plain Dart test over fixed provider values.
- Folder and note remaps move an open route through a bound navigator, and
a navigator that rejects the route does not break the remap.
- The UI request port declines confirmations and cancels prompts when
nothing is bound, and a bound port replaces it.
- Drop two empty groups left behind in the app's notes provider test.
Split ios/Runner/AppDelegate.swift into one file per bridge and put the bridges behind a host-neutral ConduitBridgeHost protocol, so each bridge can be tested and reused without the Flutter view controller. The boundary check in tool/check_package_boundaries.dart now holds every Swift file in ios/Runner to one side of that seam.
No intended behaviour change. Review follow-ups: an expiring streaming background task that keepAlive already replaced no longer reports a suspension, and the Send Image App Intent rejects files that do not resolve to an image type.
- Reject every IANA special-purpose IPv4 block and accept only global
unicast IPv6, checking the IPv4 address inside mapped, NAT64 and 6to4
addresses, so web_fetch can't reach an internally routed host.
- Bound the whole page fetch, including DNS and connection attempts,
by its timeout and cancel signal; decode windows-1252 properly.
- Keep an article's own header, lengthen code fences around embedded
backticks, and keep indentation in plain-text and Markdown pages.
- Read user links with an uppercase scheme or a balanced closing paren.
- Don't send a search whose cancel signal has already fired; match Brave
links on the host boundary; keep results on different ports apart.
- Stop bulk settings saves from overwriting web search preferences.
- Resolve curated model families before the models.dev index, and stop
indexing a lab's hosted copies of another maker's models (Gemma no
longer shows NVIDIA's logo); cache rendered logos between picker opens.
Direct models now show their maker's logo, or failing that the logo of the
provider serving them, instead of a letter tile. The logos and a compact
index come from models.dev and ship in the app bundle
(assets/model_logos, regenerated by tool/generate_model_logos.dart), so
resolving an avatar never touches the network.
- ModelLogoCatalog resolves a model by its lab prefix, the models.dev
model index, then family prefixes (llama3.2 is Meta's), and falls back
to the connection's API host or Ollama.
- Direct models record their connection's host and no longer ask the
Open WebUI server for an avatar that can't exist.
- ModelAvatar draws the SVG tinted to the theme; the native iOS picker
gets pre-rendered PNG bytes.
flutter test compiles test files one at a time and loads a full program
for each, so with ~320 files most of a run was per-file overhead rather
than tests. tool/run_test_shards.dart groups the files into 8 generated
entrypoints (each file's tests run in a group named after it), keeps files
that open real sockets standalone, runs them, and deletes the generated
files afterwards. The shard count is fixed so CI and every machine group
the same files and a cross-file failure reproduces anywhere.
CI now runs it (with a 30-minute step timeout) and tracks the latest
Flutter stable release instead of a pinned version. docs/BUILDING.md
documents the runner and no longer says CI skips the analyzer and tests.
The mobile app's non-UI code moves into pure-Dart packages in a pub
workspace, so a second front-end can share it. The app keeps working
from the repo root with no extra flags, and behaves as before.
- packages/conduit_core: auth, database and sync, services, providers
and the feature logic (direct connections, Hermes, MCP, terminal,
workspace), with platform code behind ports implemented in lib/platform.
- packages/conduit_markdown: the markdown, citation, reasoning,
tool-call and speech-text parsing, web-safe.
- packages/conduit_theme: the tweakcn palette registry as plain ARGB,
which lib/shared/theme wraps in Color.
Most files are moves; the tools that did the mechanical part
(split_into_parts, split_class_into_mixins, migrate_core_tests and the
listing helpers) are in tool/, and tool/check_package_boundaries.dart
keeps the packages free of Flutter. Fixes made along the way are
included, as are missing translations for existing mobile strings.
CI gains a workflow for the app, each package on its own, the web-safe
markdown package under dart2js, and the ARB checks.
* feat: bundled JSON release notes for 4.0, polished changelog sheet, remove IAP
- Move release-note content from ARB to assets/release_notes/<locale>.json
with locale-fallback repository and schema-checked validator
- Polish the release notes sheet (staggered reveal, version badge,
feature glyph bullets, action cards) and add the 4.0 note in 13 locales
- Prune pre-4.0 notes
- Remove in_app_purchase and the tip jar entirely; keep the Buy Me a
Coffee donation link on all platforms
* Refresh release notes sheet and localized copy
* Add release notes banner and native review support
* Redesign release announcement sheet
* Target release announcement to 4.0.1
* Address release announcement review feedback
* Tighten release note locale handling