client sdk
This commit is contained in:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user