use std::pin::Pin;
use std::process::Stdio;
use anyhow::Context as _;
use async_stream::try_stream;
use futures::Stream;
use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::process::Command;
use crate::config::CliBackendConfig;
use crate::session::Turn;
pub type ReplyStream = Pin<Box<dyn Stream<Item = anyhow::Result<String>> + Send>>;
#[derive(Debug, Clone)]
pub struct CompletionRequest {
pub system_prompt: String,
pub turns: Vec<Turn>,
}
#[async_trait::async_trait]
pub trait Backend: Send + Sync {
async fn complete(&self, req: CompletionRequest) -> anyhow::Result<ReplyStream>;
}
#[derive(Debug, Clone)]
pub struct CliBackend {
pub cmd: String,
pub args: Vec<String>,
}
impl From<&CliBackendConfig> for CliBackend {
fn from(cfg: &CliBackendConfig) -> Self {
Self {
cmd: cfg.cmd.clone(),
args: cfg.args.clone(),
}
}
}
#[async_trait::async_trait]
impl Backend for CliBackend {
async fn complete(&self, req: CompletionRequest) -> anyhow::Result<ReplyStream> {
let prompt = render_cli_prompt(&req.system_prompt, &req.turns);
let (cmd, args) = resolve_cmd(&self.cmd, &self.args);
let mut child = Command::new(&cmd)
.args(&args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
.spawn()
.with_context(|| format!("spawn {cmd}"))?;
let mut stdin = child.stdin.take().context("take stdin")?;
let stdout = child.stdout.take().context("take stdout")?;
let stderr = child.stderr.take().context("take stderr")?;
let writer = tokio::spawn(async move {
let _ = stdin.write_all(prompt.as_bytes()).await;
let _ = stdin.shutdown().await;
});
let cmd_for_log = cmd.clone();
tokio::spawn(async move {
let mut buf = String::new();
let mut reader = BufReader::new(stderr);
if reader.read_to_string(&mut buf).await.is_ok() && !buf.trim().is_empty() {
tracing::debug!(target: "kotonoha::backend", cmd = %cmd_for_log, "stderr: {buf}");
}
});
let stream = try_stream! {
let mut reader = BufReader::new(stdout);
let mut buf = [0u8; 4096];
loop {
let n = reader.read(&mut buf).await?;
if n == 0 { break; }
let chunk = String::from_utf8_lossy(&buf[..n]).into_owned();
yield chunk;
}
let status = child.wait().await?;
if !status.success() {
Err(anyhow::anyhow!("{cmd} exited with status {status}"))?;
}
let _ = writer.await;
};
Ok(Box::pin(stream))
}
}
pub fn render_cli_prompt(system_prompt: &str, turns: &[Turn]) -> String {
let mut buf = String::with_capacity(1024 + turns.len() * 64);
buf.push_str("[SYSTEM]\n");
buf.push_str(system_prompt.trim());
buf.push_str("\n\n[CONVERSATION]\n");
if turns.is_empty() {
buf.push_str("(this is the first turn — greet the student warmly in English and ask them a simple opening question.)\n");
} else {
for turn in turns {
match turn {
Turn::Student(t) => {
buf.push_str("Student: ");
buf.push_str(t.trim());
buf.push('\n');
}
Turn::Teacher(t) => {
buf.push_str("Teacher: ");
buf.push_str(t.trim());
buf.push('\n');
}
}
}
}
buf.push_str("\n[TASK]\nReply as Kotonoha-sensei (Teacher). Output only the teacher's next utterance — no labels, no quotes, no stage directions.\n");
buf
}
fn resolve_cmd(cmd: &str, args: &[String]) -> (String, Vec<String>) {
match which::which(cmd) {
Ok(resolved) => {
tracing::info!(
target: "kotonoha::backend",
"resolved cmd `{}` -> {}",
cmd,
resolved.display()
);
let ext = resolved
.extension()
.and_then(|s| s.to_str())
.map(|s| s.to_ascii_lowercase());
let resolved_str = resolved.to_string_lossy().into_owned();
match ext.as_deref() {
Some("ps1") => {
let mut full = vec![
"-NoProfile".into(),
"-ExecutionPolicy".into(),
"Bypass".into(),
"-File".into(),
resolved_str,
];
full.extend(args.iter().cloned());
tracing::info!(target: "kotonoha::backend", "→ powershell -File wrap");
("powershell".into(), full)
}
Some("cmd") | Some("bat") => {
let mut full = vec!["/C".into(), resolved_str];
full.extend(args.iter().cloned());
tracing::info!(target: "kotonoha::backend", "→ cmd.exe /C wrap");
("cmd.exe".into(), full)
}
_ => (resolved_str, args.to_vec()),
}
}
Err(e) => {
tracing::warn!(
target: "kotonoha::backend",
"`which({cmd})` failed: {e}. Looked in PATH = {:?}",
std::env::var("PATH").unwrap_or_default()
);
(cmd.to_string(), args.to_vec())
}
}
}