-
-
Notifications
You must be signed in to change notification settings - Fork 575
Surface + document the loopback Local API for on-device agents (Settings toggle + docs) #715
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
66dbd62
0e68a5d
d8ef83f
e88f574
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -56,8 +56,13 @@ final class LocalAPIServer { | |
| handler.start(on: self.queue) | ||
| } | ||
| } | ||
| listener.stateUpdateHandler = { state in | ||
| listener.stateUpdateHandler = { [weak self, weak listener] state in | ||
| Task { @MainActor in | ||
| // Ignore callbacks from a superseded listener. A rapid off→on toggle | ||
| // (now user-reachable via refresh()) can install a new listener before | ||
| // an old one's async .cancelled/.failed callback reaches the main actor; | ||
| // without this guard that stale callback would clear/cancel the new one. | ||
| guard let self, let listener, self.listener === listener else { return } | ||
| self.handleState(state) | ||
| } | ||
| } | ||
|
|
@@ -79,6 +84,17 @@ final class LocalAPIServer { | |
| self.listener = nil | ||
| } | ||
|
|
||
| /// Start or stop the server to match the current `LocalAPIEnabled` setting. | ||
| /// Call this after the user toggles the Local API preference so the change takes | ||
| /// effect immediately, without restarting the app. | ||
| func refresh() { | ||
| if LocalAPI.Configuration.current.enabled { | ||
| self.start() | ||
| } else { | ||
| self.stop() | ||
| } | ||
|
Comment on lines
+90
to
+95
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If the toggle is switched off and then back on before the cancelled listener's asynchronous state callback reaches the main actor, Useful? React with 👍 / 👎.
greptile-apps[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| private func handleState(_ state: NWListener.State) { | ||
| switch state { | ||
| case .ready: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| # Local API | ||
|
|
||
| FluidVoice ships a small, **loopback-only** HTTP API that lets other apps and AI agents | ||
| running **on the same Mac** reuse its speech-to-text and text post-processing. This avoids | ||
| each agent bundling and loading its own ASR model — FluidVoice keeps one model warm and | ||
| serves transcription requests locally. | ||
|
|
||
| ## Enabling | ||
|
|
||
| The API is **off by default**. Turn it on in `Settings → Local API (on-device agents)`. | ||
|
|
||
| You can also toggle it via `defaults`: | ||
|
|
||
| ```bash | ||
| defaults write com.FluidApp.app LocalAPIEnabled -bool true | ||
|
Comment on lines
+12
to
+15
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When FluidVoice is already running, this command only changes the preference: Useful? React with 👍 / 👎. |
||
| # optional custom port (default 47733): | ||
| defaults write com.FluidApp.app LocalAPIPort -int 47733 | ||
| ``` | ||
|
|
||
| ## Security | ||
|
|
||
| - The listener **refuses all non-loopback connections** — any non-local peer is rejected at | ||
| accept time, before it can send a request — so the API is usable only from processes on this Mac. | ||
| - It is opt-in and off by default. | ||
| - Limits: request body **25 MB**; transcription audio **300 s (5 min)** per request (longer audio is | ||
| rejected). These caps are independent — compressed audio can satisfy the byte limit yet still hit the | ||
| duration limit. | ||
|
Comment on lines
+25
to
+27
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For the documented Useful? React with 👍 / 👎. |
||
| - **Locality caveat:** `/v1/transcribe` runs fully on-device. `/v1/postprocess` runs your configured | ||
| post-processing provider (Settings → AI Enhancement); if that provider is a remote API | ||
| (OpenAI/Groq/Anthropic/…), the submitted text is sent there. Only the HTTP listener and transcription | ||
| are guaranteed local. | ||
|
|
||
| ## Endpoints | ||
|
|
||
| Base URL: `http://127.0.0.1:47733` | ||
|
|
||
| ### `GET /v1/health` | ||
|
|
||
| Liveness probe. Returns `200 OK` when the server is running. | ||
|
|
||
| ### `POST /v1/transcribe` | ||
|
|
||
| Transcribe an audio file with the currently selected speech model (reuses the already-loaded | ||
| model — no second instance). | ||
|
|
||
| Request body (JSON), one of: | ||
|
|
||
| ```jsonc | ||
| { "path": "/absolute/path/to/audio.ogg" } // transcribe a file by path | ||
| { "audioBase64": "<base64>", "filename": "clip.wav" } // or inline audio (extension hint) | ||
| ``` | ||
|
|
||
| Supported inputs include WAV, MP3, M4A, OGG and other common formats (decoded internally). | ||
|
|
||
| Response: | ||
|
|
||
| ```json | ||
| { | ||
| "text": "the transcribed text", | ||
| "confidence": 0.97, | ||
| "sampleCount": 1402136, | ||
| "provider": "Parakeet TDT v3 (Multilingual)" | ||
| } | ||
| ``` | ||
|
|
||
| ### `POST /v1/postprocess` | ||
|
|
||
| Run FluidVoice's text post-processing/enhancement on a string. | ||
|
|
||
| ```json | ||
| { "text": "raw text to clean up" } | ||
| ``` | ||
|
|
||
| Response: | ||
|
|
||
| ```json | ||
| { "text": "cleaned text", "provider": "…", "model": "…" } | ||
| ``` | ||
|
|
||
| ## Example integrations | ||
|
|
||
| Shell: | ||
|
|
||
| ```bash | ||
| curl -s http://127.0.0.1:47733/v1/transcribe \ | ||
| -H 'Content-Type: application/json' \ | ||
| -d '{"path": "'"$PWD"'/memo.ogg"}' | jq -r .text | ||
| ``` | ||
|
|
||
| Python: | ||
|
|
||
| ```python | ||
| import requests | ||
| r = requests.post("http://127.0.0.1:47733/v1/transcribe", | ||
| json={"path": "/tmp/memo.ogg"}, timeout=90) | ||
| print(r.json()["text"]) | ||
| ``` | ||
|
|
||
| ## Notes | ||
|
|
||
| - If the selected model is not resident yet, the first request loads it; subsequent requests | ||
| are fast while it stays warm. | ||
| - Other endpoints exist for history and the custom dictionary (`GET /v1/history`, | ||
| `GET|POST /v1/dictionary/*`); this document focuses on the ones most useful to agents. | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a connection has passed
newConnectionHandlerbut itsTask { @MainActor ... }has not executed yet, this call tostop()cannot see it inactiveConnections. The queued task then creates and starts the handler without checking the enabled setting or its originating listener, allowing that connection to continue serving requests after the user switches the API off. Associate connection callbacks with the listener generation and cancel them when that listener is no longer current.Useful? React with 👍 / 👎.