client sdk

This commit is contained in:
valenti
2026-10-21 17:56:04 +01:00
parent 1cafe3dd3c
commit d6bc2437c9
237 changed files with 5227 additions and 1019 deletions
+130 -37
View File
@@ -13,8 +13,9 @@ use crate::audit::SessionAudit;
use crate::config::Config;
use crate::lang::{self, LangBanks, LanguageState};
use crate::llm::{ChatMessage, LlmClient};
use crate::prompt::SessionPrompts;
use crate::stt::{Tokenizer, Transcript};
use crate::tools::ToolCallStripper;
use crate::tools::{ChatMarkdownStripper, ToolCallStripper};
use crate::tts::TtsEngine;
use crate::{assets, orchestrator::backchannel::DEFAULT_CATEGORY};
use serde_json::json;
@@ -48,10 +49,7 @@ fn pump(src: &SampleRingBuffer, dst: &SampleRingBuffer) -> usize {
let mut tmp = [0i16; 8192];
let mut moved = 0;
loop {
let want = dst
.remaining_capacity()
.min(src.len())
.min(tmp.len());
let want = dst.remaining_capacity().min(src.len()).min(tmp.len());
if want == 0 {
break;
}
@@ -102,7 +100,9 @@ impl FillerBank {
info!(label, name, samples = pcm.len(), "loaded {label} clip");
samples.insert(name, pcm);
}
Err(e) => warn!(label, path = %path.display(), error = %e, "failed to read {label} clip"),
Err(e) => {
warn!(label, path = %path.display(), error = %e, "failed to read {label} clip")
}
}
}
if samples.is_empty() {
@@ -209,6 +209,11 @@ pub struct TurnManager {
/// other event reaches the log through the outbound tap in the session
/// sender — see [`crate::audit`]).
audit: Arc<SessionAudit>,
/// Per-session system-prompt registry: the effective prompt can be
/// overridden at runtime (see [`crate::prompt_api`]).
prompts: Arc<SessionPrompts>,
/// This session's id, the key into [`Self::prompts`].
session_id: String,
}
impl TurnManager {
@@ -221,6 +226,8 @@ impl TurnManager {
config: Arc<Config>,
out_tx: Option<mpsc::UnboundedSender<String>>,
audit: Arc<SessionAudit>,
prompts: Arc<SessionPrompts>,
session_id: String,
) -> Self {
Self {
mixer,
@@ -236,6 +243,8 @@ impl TurnManager {
lang_turns: AtomicU64::new(0),
lang_detecting: Arc::new(AtomicBool::new(false)),
audit,
prompts,
session_id,
}
}
@@ -344,9 +353,8 @@ impl TurnManager {
mixer.filler_queue.push(&samples);
debug!(filler = %name, samples = samples_len, delay_ms, "pushed filler (delayed)");
if let Some(tx) = &out_tx {
let _ = tx.send(
json!({ "type": "cue", "kind": "filler", "name": name }).to_string(),
);
let _ =
tx.send(json!({ "type": "cue", "kind": "filler", "name": name }).to_string());
}
});
}
@@ -481,7 +489,14 @@ impl TurnManager {
hist.drain(..drop);
}
let mut msgs = Vec::with_capacity(hist.len() + 1);
let system = self.config.llm_system_prompt.trim();
// Effective prompt: per-session override, else process-wide override,
// else the configured default (`LLM_SYSTEM_PROMPT`).
let effective = self.prompts.resolve(
Some(&self.session_id),
&self.config.llm_system_prompt,
self.config.llm_system_prompt_from_env,
);
let system = effective.prompt.trim();
if !system.is_empty() {
msgs.push(ChatMessage::system(system));
}
@@ -546,8 +561,9 @@ impl TurnManager {
if cancel_task.is_cancelled() {
return;
}
if let Err(e) =
tts_task_engine.synthesize(&sentence, &buf_task, &cancel_task).await
if let Err(e) = tts_task_engine
.synthesize(&sentence, &buf_task, &cancel_task)
.await
{
error!(error = %e, "TTS synthesis failed (pipelined)");
}
@@ -606,7 +622,7 @@ impl TurnManager {
// by the next turn without a restart.
let registry = llm.tools();
let max_rounds = self.config.llm_tool_max_iterations.max(1);
let mut spoken = String::new();
let mut shown = String::new();
let mut round = 0usize;
loop {
@@ -618,7 +634,9 @@ impl TurnManager {
let round_messages = messages.clone();
let llm_task = tokio::spawn(async move {
if stream_mode {
llm_round.chat_stream(&round_messages, &llm_cancel, delta_tx).await
llm_round
.chat_stream(&round_messages, &llm_cancel, delta_tx)
.await
} else {
let res = llm_round.chat(&round_messages, &llm_cancel).await;
if let Ok(text) = &res {
@@ -637,6 +655,7 @@ impl TurnManager {
self.config.llm_stream_chunk_chars,
);
let mut stripper = ToolCallStripper::new();
let mut markdown = ChatMarkdownStripper::new();
let mut raw = String::new();
while let Some(delta) = delta_rx.recv().await {
if cancel.is_cancelled() {
@@ -644,12 +663,20 @@ impl TurnManager {
}
raw.push_str(&delta);
let visible = stripper.push(&delta);
if visible.is_empty() {
let (spoken_delta, markdown_docs) = markdown.push(&visible);
for doc in markdown_docs {
if !shown.trim().is_empty() {
shown.push_str("\n\n");
}
shown.push_str(&doc);
self.emit(json!({ "type": "assistant_markdown", "text": doc }));
}
if spoken_delta.is_empty() {
continue;
}
self.emit(json!({ "type": "assistant_delta", "text": visible }));
spoken.push_str(&visible);
for sentence in splitter.push(&visible) {
self.emit(json!({ "type": "assistant_delta", "text": spoken_delta }));
shown.push_str(&spoken_delta);
for sentence in splitter.push(&spoken_delta) {
if sentence_tx.send(sentence).is_err() {
break;
}
@@ -657,6 +684,23 @@ impl TurnManager {
}
if !cancel.is_cancelled() {
let tail = stripper.finish();
let (spoken_tail, markdown_docs) = markdown.push(&tail);
for doc in markdown_docs {
if !shown.trim().is_empty() {
shown.push_str("\n\n");
}
shown.push_str(&doc);
self.emit(json!({ "type": "assistant_markdown", "text": doc }));
}
let (spoken_rest, markdown_rest) = markdown.finish();
for doc in markdown_rest {
if !shown.trim().is_empty() {
shown.push_str("\n\n");
}
shown.push_str(&doc);
self.emit(json!({ "type": "assistant_markdown", "text": doc }));
}
let tail = format!("{spoken_tail}{spoken_rest}");
if !tail.is_empty() {
self.emit(json!({ "type": "assistant_delta", "text": tail }));
for sentence in splitter.push(&tail) {
@@ -664,7 +708,7 @@ impl TurnManager {
break;
}
}
spoken.push_str(&tail);
shown.push_str(&tail);
}
if let Some(rest) = splitter.finish() {
let _ = sentence_tx.send(rest);
@@ -699,7 +743,7 @@ impl TurnManager {
if calls.is_empty()
&& registry.is_some()
&& !cancel.is_cancelled()
&& spoken.trim().is_empty()
&& shown.trim().is_empty()
&& round + 1 < max_rounds
{
match llm.chat_reply(&messages, &cancel).await {
@@ -707,10 +751,26 @@ impl TurnManager {
calls = reply.tool_calls;
if calls.is_empty() && !reply.text.trim().is_empty() {
// No call after all: the blocking reply holds the
// actual answer — speak it rather than go silent.
self.emit(json!({ "type": "assistant_delta", "text": &reply.text }));
spoken.push_str(&reply.text);
let _ = sentence_tx.send(reply.text.trim().to_string());
// actual answer. Split out any chat-only markdown
// so long documents are shown in chat but not read aloud.
let mut markdown = ChatMarkdownStripper::new();
let (spoken_reply, markdown_docs) = markdown.push(&reply.text);
let (spoken_tail, markdown_rest) = markdown.finish();
for doc in markdown_docs.into_iter().chain(markdown_rest.into_iter()) {
if !shown.trim().is_empty() {
shown.push_str("\n\n");
}
shown.push_str(&doc);
self.emit(json!({ "type": "assistant_markdown", "text": doc }));
}
let spoken_reply = format!("{spoken_reply}{spoken_tail}");
if !spoken_reply.trim().is_empty() {
self.emit(
json!({ "type": "assistant_delta", "text": &spoken_reply }),
);
shown.push_str(&spoken_reply);
let _ = sentence_tx.send(spoken_reply.trim().to_string());
}
}
}
Err(e) => error!(error = %e, "tool-call fallback request failed"),
@@ -748,8 +808,10 @@ impl TurnManager {
drop(sentence_tx);
let _ = tts_task.await;
if !cancel.is_cancelled() && !spoken.trim().is_empty() {
self.history.lock().push(ChatMessage::assistant(spoken.clone()));
if !cancel.is_cancelled() && !shown.trim().is_empty() {
self.history
.lock()
.push(ChatMessage::assistant(shown.clone()));
self.emit(json!({ "type": "assistant_end" }));
}
@@ -943,13 +1005,43 @@ async fn switch_language_if_needed(
// Idempotent: an unchanged phrase list is skipped via the per-directory
// `texts.txt` index, so an already-generated language is just reloaded.
assets::ensure_lang_backchannel_clips(&config.backchannel.dir, &lang, &phrases, tts.as_ref())
.await;
assets::ensure_lang_filler_clips(&config.fillers_dir, &lang, &filler, tts.as_ref()).await;
let voice_profile = config.cue_voice_profile();
assets::migrate_legacy_cue_bank(
&config.backchannel.dir,
&lang,
&voice_profile,
"backchannel",
);
assets::migrate_legacy_cue_bank(&config.fillers_dir, &lang, &voice_profile, "filler");
if start_filler.is_some() {
assets::migrate_legacy_cue_bank(
&config.start_filler_dir,
&lang,
&voice_profile,
"start-filler",
);
}
assets::ensure_lang_backchannel_clips(
&config.backchannel.dir,
&lang,
&voice_profile,
&phrases,
tts.as_ref(),
)
.await;
assets::ensure_lang_filler_clips(
&config.fillers_dir,
&lang,
&voice_profile,
&filler,
tts.as_ref(),
)
.await;
if let Some(texts) = &start_filler {
assets::ensure_lang_start_filler_clips(
&config.start_filler_dir,
&lang,
&voice_profile,
texts,
tts.as_ref(),
)
@@ -957,15 +1049,15 @@ async fn switch_language_if_needed(
}
let backchannel_bank = Arc::new(FillerBank::load(
&assets::lang_dir(&config.backchannel.dir, &lang),
&assets::cue_bank_dir(&config.backchannel.dir, &lang, &voice_profile),
"backchannel",
));
let filler_bank = Arc::new(FillerBank::load(
&assets::lang_dir(&config.fillers_dir, &lang),
&assets::cue_bank_dir(&config.fillers_dir, &lang, &voice_profile),
"filler",
));
let start_filler_bank = Arc::new(FillerBank::load(
&assets::lang_dir(&config.start_filler_dir, &lang),
&assets::cue_bank_dir(&config.start_filler_dir, &lang, &voice_profile),
"start-filler",
));
@@ -985,10 +1077,7 @@ async fn switch_language_if_needed(
/// Spawn an orchestrator loop that consumes transcripts from `rx` and drives
/// the turn manager.
pub fn spawn_orchestrator(
manager: Arc<TurnManager>,
mut rx: mpsc::UnboundedReceiver<Transcript>,
) {
pub fn spawn_orchestrator(manager: Arc<TurnManager>, mut rx: mpsc::UnboundedReceiver<Transcript>) {
tokio::spawn(async move {
while let Some(transcript) = rx.recv().await {
manager.on_eou(&transcript).await;
@@ -1133,7 +1222,11 @@ mod tests {
// The next run must reach the larger threshold before flushing.
let second = s.push(&"palabra ".repeat(20)); // 160 bytes
assert_eq!(second.len(), 1);
assert!(second[0].len() >= 120, "chunk too short: {}", second[0].len());
assert!(
second[0].len() >= 120,
"chunk too short: {}",
second[0].len()
);
}
#[test]