Files
ragflow/internal/agent/audio/tts_design.md
Zhichang Yu e45659868a feat(agent): ship the Go agent canvas port — eino interrupt/resume + Redis check-pointing (#16035)
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>
2026-06-17 13:24:03 +08:00

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 LLMBundle abstraction (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:

  1. 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.
  2. The Go rag/llm package already has the same factory pattern for chat / embedding / rerank models. TTS slots into the same plumbing.
  3. 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.
  4. 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

  1. New file internal/rag/llm/tts_model.go (Go side) with:
    • TTSModel interface: Synthesize(ctx, text) (io.Reader, error)
    • FishTTS, OpenAITTS, XinferenceTTS, StepFunTTS, LiteLLMProxyTTS implementations.
    • TTSFactory registry mirroring the Python one.
  2. Wire audio.Synthesizer in the existing scaffold to delegate to rag/llm/tts_model (replacing the invented shell-out protocol in the current scaffold).
  3. New file internal/rag/llm/tts_model_test.go with 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 shellSynthesizer and InstallShellSynthesizer, leaving only the stub that returns ErrTTSEngineNotConfigured, or
  • (b) replace them with a thin Python-subprocess client using the proven rag.llm.tts_model entry 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.