Replaces the Python agent canvas runtime with a Go implementation that runs inside `cmd/server_main`. The canvas compiles into an eino Workflow that pauses on wait-for-user via native Interrupt/Resume (no sentinel flag) and resumes from a Redis-backed CheckPointStore. All 21 Python agent components and ~35 tools are ported with functional parity. Sandbox providers now read their JSON config from the admin-panel system_settings table with env fallback. 234 files / +35,413 / -6,111. All Go files are gofmt-clean (CI gate added); drops the v2 DSL E2E step and the gap-analysis plan (both redundant after the port ships). ## Type of change - [x] Refactoring - [x] New feature - [x] Bug fix 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <noreply@anthropic.com>
6.1 KiB
TTS engine — Phase 8b design decision
Status: decision recorded, implementation pending. The TTS
scaffold exists (internal/agent/audio/tts.go); this doc records
how a real engine should be wired in. The current scaffold's
shellSynthesizer uses an invented --engine --text --voice --lang
protocol that no real TTS binary matches — it must not be used
as-is.
Context (what the Python side actually does)
The Python agent/canvas.py:518-521 does NOT use gtts or
edge-tts shell-out. It looks up the tenant's default TTS model
via get_tenant_default_model_by_type(self._tenant_id, LLMType.TTS)
and creates an LLMBundle(tenant, tts_model_config). The TTS
factory in rag/llm/tts_model.py dispatches to one of several
HTTP-based providers:
| Provider | Backend | Pure Go? |
|---|---|---|
| FishAudioTTS | HTTP POST to api.fish.audio/v1/tts (msgpack body) |
feasible |
| QwenTTS | DashScope SDK (over WebSocket) | heavy |
| OpenAITTS | HTTP POST to OpenAI-compatible /audio/tts |
feasible |
| StepFunTTS | HTTP POST to vendor endpoint | feasible |
| RAGconTTS | LiteLLM proxy (HTTP) | feasible |
| XinferenceTTS | HTTP POST to xinference | feasible |
| TongyiTTS | DashScope SDK | heavy |
None of the production providers are gtts / edge-tts. The "gtts or edge-tts shell-out" wording in the original plan was a placeholder that didn't survive the v3.1 review; the production TTS layer is HTTP / SDK all the way down.
Options for the Go port
A. Reimplement the HTTP clients in Go
Write Go HTTP clients for each provider (Fish / OpenAI / StepFun / Xinference / LiteLLM-proxy). Skip the DashScope SDK variants for now (Qwen, Tongyi) — those are websocket-heavy and can follow in a later phase.
- Pros
- No Python dependency on the Go side.
- Lower latency, no subprocess overhead per call.
- Clean integration with the rest of the Go runtime.
- Cons
- Five HTTP surfaces to maintain in lockstep with the Python ones. Every vendor release needs a Go update.
- The Python TTS layer is part of a multi-tenant
LLMBundleabstraction (config, key rotation, retry policy) — reimplementing just the wire layer loses those cross-cutting concerns.
B. Shell out to a Python subprocess that uses rag.llm.tts_model
Spawn python3 -c "from rag.llm.tts_model import ...; ..." and
pipe the audio bytes back.
- Pros
- Reuses the verified Python TTS layer verbatim — including all providers, the LLMBundle config / key handling, and the retry / streaming logic.
- Plan §2.11.4-style "don't rewrite the vendor layer" applies here too: rag/llm/tts_model.py IS the vendor layer.
- One Python subprocess call covers all current providers.
- Cons
- Per-call latency = Python interpreter startup + TTS module import + HTTP to vendor. ~hundreds of ms.
- Adds a Python dependency on the Go host.
- Stream chunks back from Python are awkward (binary audio).
C. Hybrid — reimplement the simple HTTP ones, shell out for the rest
Reimplement OpenAI / Fish / Xinference / StepFun / LiteLLM-proxy (5 providers, all straightforward HTTP). Shell out only for the DashScope-SDK providers (Qwen, Tongyi) — those are websocket clients whose cost / benefit doesn't justify a reimplementation until the Go side has more DashScope users.
- Pros
- Common case (OpenAI-compatible / LiteLLM proxy) has no Python dep.
- Avoids the worst of option B (latency + Python dep) for the providers most users actually deploy.
- Cons
- Two code paths to maintain (Go HTTP + Python SDK).
- DashScope providers are exactly the ones several Chinese RAGFlow operators use — leaving them on the Python fallback is a real capability gap.
Decision
Option A (reimplement the HTTP clients in Go). Reasoning:
- All non-DashScope providers are straightforward HTTP POSTs with JSON / msgpack bodies. None of them need a streaming audio reader that's complex enough to justify a Python subprocess call.
- The Go
rag/llmpackage already has the same factory pattern for chat / embedding / rerank models. TTS slots into the same plumbing. - Option B's latency cost (~hundreds of ms for a Python interpreter + module import) is wasted — audio synthesis takes seconds anyway; the Python-startup overhead is noise compared to the network round-trip.
- Option C's hybrid is the worst of both worlds: a new Go codebase that has to keep parity with a Python fallback for the DashScope providers.
For the DashScope SDK providers (Qwen, Tongyi), we accept
"not yet supported in Go" — the user can fall back to the
Python Canvas. Loud-fail via the existing
ErrTTSEngineNotConfigured pattern.
Implementation sketch
- New file
internal/rag/llm/tts_model.go(Go side) with:TTSModelinterface:Synthesize(ctx, text) (io.Reader, error)FishTTS,OpenAITTS,XinferenceTTS,StepFunTTS,LiteLLMProxyTTSimplementations.TTSFactoryregistry mirroring the Python one.
- Wire
audio.Synthesizerin the existing scaffold to delegate torag/llm/tts_model(replacing the invented shell-out protocol in the current scaffold). - New file
internal/rag/llm/tts_model_test.gowith HTTP mock tests for each provider's request shape.
What the existing scaffold needs to change
The current shellSynthesizer.Synthesize uses
--engine --text --voice --lang argv — no real TTS binary
matches that. Until option A lands, the existing scaffold
should be considered a placeholder. A subsequent commit
should either:
- (a) delete
shellSynthesizerandInstallShellSynthesizer, leaving only the stub that returnsErrTTSEngineNotConfigured, or - (b) replace them with a thin Python-subprocess client
using the proven
rag.llm.tts_modelentry point (a safe interim — same pattern as CodeExec).
Option (a) is the safer default: it removes a footgun (a non-functional shell-out) and lets the operator discover the deferred state through the standard "ErrTTSEngineNotConfigured" error. Option (b) is the "ship a working path today" choice.
The choice between (a) and (b) is left to the implementer
of option A; until then, the scaffold's shellSynthesizer
is dead code.