跳转至

crates\nfarms-core\src\chat.rs

源码路径:crates\nfarms-core\src\chat.rs · 520 行

//! 桌宠对话框的后端:调本地推理服务的 OpenAI 兼容接口。
//!
//! # 为什么不走 DSH
//!
//! DSH 是**会话式**的:它管历史、工具调用、权限、上下文裁剪。而桌宠这里要的是
//! 「问一句答一句」的一次性对话 —— 把它塞进 DSH 的会话模型里,两边都别扭。
//!
//! 直接打 llama-server 还少一个会坏的地方:**DSH 没启动时,桌宠照样能说话**。
//!
//! # 这个模块只做纯逻辑
//!
//! `ask` 会真的发请求,但它只依赖一个 `base_url` —— 谁传进来的、是哪个后端,
//! 它都不关心。所以测试可以直接对着正在跑的 `llama-server` 打
//! (见 `mod tests`),也可以对着一个不存在的端口断言错误路径。

use serde_json::json;
use std::time::{Duration, Instant};

/// 对话里的一条消息。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Msg {
    pub role: &'static str,
    pub content: String,
}

impl Msg {
    pub fn system(s: impl Into<String>) -> Self {
        Self {
            role: "system",
            content: s.into(),
        }
    }
    pub fn user(s: impl Into<String>) -> Self {
        Self {
            role: "user",
            content: s.into(),
        }
    }
    pub fn assistant(s: impl Into<String>) -> Self {
        Self {
            role: "assistant",
            content: s.into(),
        }
    }
}

/// 桌宠的人格。**写死在代码里**,不让用户配 —— 一个桌面宠物不需要人设编辑器,
/// 而留一堆可配项只会让「它怎么突然不这么说话了」变成一个要查的问题。
pub const PERSONA: &str = "你是一只住在 Windows 桌面上的鲸鱼娘,名字叫小鲸。\
回答要短(通常一两句)、口语化、带点可爱,但不要用颜文字堆砌。\
用户问技术问题时正常回答,不要卖萌耽误事。";

/// 默认最多生成多少 token。
///
/// 桌宠的回答本来就该短 —— 而且本地小模型生成慢,给太大只是让它在那儿打很久字。
pub const DEFAULT_MAX_TOKENS: u32 = 512;

/// 一次请求最多等多久。
///
/// ureq 的默认超时对本地的 30B MoE **不够**:实测出一段两百字的回答要十几秒,
/// 冷启动(模型还在加载)更久。给到 5 分钟,超时了就报错而不是永远转圈。
const TIMEOUT_SECS: u64 = 300;

/// `draw` 最多等 `sd-cli` 多久。
///
/// **和上面那个 HTTP 超时是两件事**:`TIMEOUT_SECS` 给的是 `ureq` 的
/// `timeout_global`,它管的是「HTTP 响应等多久」,而 `draw` 等的是一个**子进程**
/// ——HTTP 的超时对子进程一点用都没有(这正是出图会永久卡死的原因)。
///
/// 取 10 分钟,比 HTTP 那个还宽:出图实测 148 秒,冷启动时前面还要加加载
/// 8.8 GB 权重的时间。它**只当「卡死了」的兜底**,不是性能判据 —— 真按 5 分钟
/// 去砍,慢一点但正常的出图会被误杀,那比没有超时更糟。
const DRAW_TIMEOUT_SECS: u64 = 600;

fn agent() -> ureq::Agent {
    ureq::Agent::config_builder()
        .timeout_global(Some(Duration::from_secs(TIMEOUT_SECS)))
        .build()
        .into()
}

/// 只要端口上没人在听,`ureq` 报的就是这句(拿它区分「服务没起」和「别的问题」)。
///
/// 这个区分对用户很重要:前者要去 Model › llama.cpp 点「启动服务」,
/// 后者是配置或模型的问题 —— 而两件事的**处理方式完全不同**。
pub fn looks_like_connection_refused(e: &str) -> bool {
    let l = e.to_ascii_lowercase();
    l.contains("connection refused")
        || l.contains("actively refused")
        || l.contains("10061")
        || l.contains("connect error")
}

/// 组装请求体(纯函数,单独拎出来是为了能测)。
pub fn build_payload(model: &str, msgs: &[Msg], max_tokens: u32) -> serde_json::Value {
    json!({
        "model": model,
        "messages": msgs
            .iter()
            .map(|m| json!({ "role": m.role, "content": m.content }))
            .collect::<Vec<_>>(),
        "max_tokens": max_tokens,
        // 不用流式:桌宠的回答短,一次性拿回来比分块拼接简单得多
        "stream": false
    })
}

/// 从响应体里抠出回复正文(纯函数)。
///
/// 分开测的理由和 `build_payload` 一样:HTTP 能不能通取决于环境,
/// 而**解析逻辑对不对是代码自己的事**,不该被环境的成败掩盖。
pub fn parse_reply(body: &str) -> Result<String, String> {
    let v: serde_json::Value =
        serde_json::from_str(body).map_err(|e| format!("响应不是合法 JSON: {e}"))?;
    // 服务端出错时返回的是 {"error": {...}},这条要先认出来 ——
    // 不然会被当成「choices 是空的」,报出一个和真实原因无关的错。
    if let Some(err) = v.get("error") {
        let m = err
            .get("message")
            .and_then(|x| x.as_str())
            .unwrap_or("服务端返回了错误");
        return Err(format!("服务端: {m}"));
    }
    let text = v
        .get("choices")
        .and_then(|c| c.get(0))
        .and_then(|c| c.get("message"))
        .and_then(|m| m.get("content"))
        .and_then(|c| c.as_str())
        .ok_or_else(|| "响应里没有 choices[0].message.content".to_string())?;
    let t = text.trim();
    if t.is_empty() {
        return Err("模型返回了空回复".to_string());
    }
    Ok(t.to_string())
}

/// 一轮回复:正文 + 用量。
///
/// **用量单独带出来**,不直接丢掉 —— 界面上有一个「Tokens」页要显示它。
/// 服务端本来就在响应里给了(`usage`),不取白不取;而一旦丢了,
/// 想再要就得自己数 token,那和真实值对不上。
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Reply {
    pub text: String,
    pub prompt_tokens: u32,
    pub completion_tokens: u32,
}

/// 从响应里取用量。缺字段就是 0 —— **不能因为服务端没给用量就判定请求失败**。
pub fn parse_usage(body: &str) -> (u32, u32) {
    let Ok(v) = serde_json::from_str::<serde_json::Value>(body) else {
        return (0, 0);
    };
    let g = |k: &str| v.get("usage").and_then(|u| u.get(k)).and_then(|x| x.as_u64()).unwrap_or(0) as u32;
    (g("prompt_tokens"), g("completion_tokens"))
}

/// 发一轮对话,返回回复正文。
///
/// `base_url` 形如 `http://127.0.0.1:8080/v1` —— 和 DSH 的 provider 用的是**同一个**
/// 地址,所以端口改了这里跟着走(唯一真值在 Setting › Proxy and Port)。
pub fn ask(
    base_url: &str,
    model: &str,
    msgs: &[Msg],
    max_tokens: u32,
) -> Result<Reply, String> {
    let url = format!("{}/chat/completions", base_url.trim_end_matches('/'));
    let payload = build_payload(model, msgs, max_tokens);
    let body = payload.to_string();
    let mut resp = agent()
        .post(&url)
        .header("Content-Type", "application/json")
        .send(body)
        .map_err(|e| {
            let s = e.to_string();
            if looks_like_connection_refused(&s) {
                format!("连不上 {url} —— 本地推理服务没在跑?(Model › llama.cpp › 启动服务)")
            } else {
                format!("请求失败: {s}")
            }
        })?;
    let body = resp
        .body_mut()
        .read_to_string()
        .map_err(|e| format!("读响应失败: {e}"))?;
    let text = parse_reply(&body)?;
    let (pt, ct) = parse_usage(&body);
    Ok(Reply {
        text,
        prompt_tokens: pt,
        completion_tokens: ct,
    })
}

/// 让 stable-diffusion.cpp 画一张图。**阻塞几分钟**,调用方必须放进 `Job`。
///
/// # 为什么走 `sd-cli` 而不是 `sd-server`
///
/// 桌宠画图是"要一张图"的一次性动作,不是常驻服务。`sd-cli` 起来→出图→退出,
/// 不用管生命周期;`sd-server` 要另起进程、管端口、管退出,而这一页并不需要
/// 连续出图。**等真需要连续出图(比如批量)时再换**,那时 server 的收益才成立。
///
/// 出图很慢:这个会话里实测 4 GB 扩散权重 + 4.8 GB text encoder 跑一次
/// **148 秒**。所以超时给得很宽(`DRAW_TIMEOUT_SECS`),而不是让它中途失败。
pub fn draw(
    prompt: &str,
    out: &std::path::Path,
    model_dir: &std::path::Path,
    sd_cli: &std::path::Path,
    w: u32,
    h: u32,
) -> Result<(), String> {
    draw_with_timeout(
        prompt,
        out,
        model_dir,
        sd_cli,
        w,
        h,
        Duration::from_secs(DRAW_TIMEOUT_SECS),
    )
}

/// `draw` 的本体。超时值单独当参数,只为了让测试能用几百毫秒**真的**构造出
/// 「`sd-cli` 不退出」的场景 —— 拿真的 10 分钟去测,测试就没法跑了。
fn draw_with_timeout(
    prompt: &str,
    out: &std::path::Path,
    model_dir: &std::path::Path,
    sd_cli: &std::path::Path,
    w: u32,
    h: u32,
    timeout: Duration,
) -> Result<(), String> {
    let args = crate::libs::sd_cli_args(prompt, out, model_dir, w, h);
    let mut cmd = std::process::Command::new(sd_cli);
    cmd.args(&args);
    // `output()` 会默认把两路管道接走,`spawn()` 不会:不显式给 `piped`,
    // 子进程会继承本进程的 stdout/stderr,失败时想看的报错就抓不到了。
    cmd.stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped());
    // **不弹控制台**:GUI 里起子进程不加这个会闪一个黑框出来
    #[cfg(windows)]
    {
        use std::os::windows::process::CommandExt;
        const CREATE_NO_WINDOW: u32 = 0x0800_0000;
        cmd.creation_flags(CREATE_NO_WINDOW);
    }
    let child = cmd
        .spawn()
        .map_err(|e| format!("起不了 sd-cli({}): {e}", sd_cli.display()))?;
    let r = wait_with_timeout(child, timeout).map_err(|e| match e {
        // 报错要指明**是超时**。写成「退出码 xxx」的话用户会去查参数,
        // 而真实原因通常是模型太大 / 显存不足 / 后端挂住。
        WaitError::Timeout => format!(
            "出图超时(>{} 秒),已终止 sd-cli —— 卡在加载或采样上了;\
换更小的模型,或看 Model 页的日志再试",
            timeout.as_secs()
        ),
        WaitError::Failed(e) => format!("等 sd-cli 结束失败: {e}"),
    })?;
    if !r.status.success() {
        let err = String::from_utf8_lossy(&r.stderr);
        let tail: String = err.chars().rev().take(800).collect::<String>().chars().rev().collect();
        return Err(format!("sd-cli 退出码 {:?}:{tail}", r.status.code()));
    }
    if !out.exists() {
        return Err("sd-cli 报成功,但输出文件不在 —— 参数可能没被它认".to_string());
    }
    Ok(())
}

/// `wait_with_timeout` 的两条失败路径。
///
/// **分开**是因为它们对用户的意义完全不同:超时要能说一句「我把它杀了」,
/// 而 `try_wait` 自己出错是系统层面的事(句柄没了之类),混成一句话会把人带偏。
enum WaitError {
    Timeout,
    Failed(std::io::Error),
}

/// 等一个**已经 `spawn` 出来**的子进程结束,最多等 `timeout`;到点就把它杀掉。
///
/// # 为什么不用 `Command::output()`
///
/// `output()` 会一直阻塞到子进程退出并读完管道 —— **std 里没有任何给子进程设
/// 超时的 API**。`sd-cli` 一旦卡住(显存不足、后端挂住),`draw` 就永远不返回:
/// 界面上的转圈永不停,而 `Job::start` 的 `if self.running { return }` 还会把
/// 之后每一次出图请求**静默丢掉**,于是这一次运行里的出图功能等于废了,只能重启。
/// 所以这里自己 `try_wait` 轮询(写法照 `tools.rs::capture` 已有的那份)。
///
/// # 为什么两路管道各起一个线程读
///
/// 子进程的输出超过管道缓冲区(Windows 上 64 KB)时会阻塞在写管道上,而阻塞中的
/// 子进程不会退出 —— 只轮询 `try_wait` 会把「输出太多」误判成「超时」,还会把已经
/// 写出来的输出一起丢掉。读线程一挂上,子进程就能一直写完。
fn wait_with_timeout(
    mut child: std::process::Child,
    timeout: Duration,
) -> Result<std::process::Output, WaitError> {
    let out_handle = child.stdout.take().map(|mut p| {
        std::thread::spawn(move || {
            use std::io::Read;
            let mut buf = Vec::new();
            let _ = p.read_to_end(&mut buf);
            buf
        })
    });
    let err_handle = child.stderr.take().map(|mut p| {
        std::thread::spawn(move || {
            use std::io::Read;
            let mut buf = Vec::new();
            let _ = p.read_to_end(&mut buf);
            buf
        })
    });

    let deadline = Instant::now() + timeout;
    let status = loop {
        match child.try_wait() {
            Ok(Some(st)) => break Ok(st),
            Ok(None) => {
                if Instant::now() >= deadline {
                    let _ = child.kill();
                    // kill 之后照样 wait 一次:把进程句柄收掉,免得留在自己手里。
                    let _ = child.wait();
                    break Err(WaitError::Timeout);
                }
                // 20 ms:出图以分钟计,这个粒度足够,又不会把 CPU 占住
                std::thread::sleep(Duration::from_millis(20));
            }
            Err(e) => break Err(WaitError::Failed(e)),
        }
    };

    match status {
        Ok(st) => {
            let stdout = out_handle.and_then(|h| h.join().ok()).unwrap_or_default();
            let stderr = err_handle.and_then(|h| h.join().ok()).unwrap_or_default();
            Ok(std::process::Output {
                status: st,
                stdout,
                stderr,
            })
        }
        // 超时这条路上**故意不 join 读线程**:`kill` 只杀直接子进程,它要是留下了
        // 继承了管道句柄的后代(比如批处理里的 `ping`),join 会白等到那个后代自己
        // 退出 —— 那就等于没超时。句柄关掉之后这两个线程自己会结束,放它们走。
        Err(e) => Err(e),
    }
}

/// 读 llama-server 的 Prometheus 指标。
///
/// # 为什么不用它替代本地累计
///
/// 这是**服务端进程**的累计值,所以它同时包含 DSH 那边的调用 —— 比只统计
/// 桌宠对话框完整。但它是"服务器起来之后的总量",分不清是谁用的,
/// 也和对话的上下文窗口没关系。两个视角各有各的用,所以**两个都留**。
///
/// # 为什么不解析成数字
///
/// 我**没有核实过**这个版本 llama.cpp 的指标名。猜一个 `llamacpp:tokens_*`
/// 出来,如果名字变了就会显示成 0 —— 而那看起来像"没用过 token",
/// 是个会骗人的错。所以这里把**含 token 的原始行**交给界面照显,
/// 名字对不对用户一眼就能看见。
pub fn fetch_metrics(base_url: &str) -> Result<Vec<String>, String> {
    // **指标在根路径,不在 /v1 下面。** 传进来的 base 形如
    // `http://127.0.0.1:8080/v1`(和对话同一个地址),直接接 `/metrics`
    // 会请求成 `/v1/metrics` —— 服务在跑却回 404。
    // 404 和「连不上」长得完全不同,但都能让人以为指标功能坏了。
    let root = base_url
        .trim_end_matches('/')
        .trim_end_matches("/v1");
    let url = format!("{root}/metrics");
    let mut r = agent()
        .get(&url)
        .call()
        .map_err(|e| {
            let s = e.to_string();
            if looks_like_connection_refused(&s) {
                format!("连不上 {url} —— 本地推理服务没在跑?")
            }
            else if s.contains("501") {
                // 501 = 接口在,但没启用。这个区分很重要:
                // 「连不上」要去启动服务,「501」是要带 --metrics 重启 ——
                // 两件事的处理方式完全不同,而报错长得一样近。
                "服务在跑,但指标接口没开(501)。\
用本程序 Model › llama.cpp 启动的服务会自动带上 --metrics;\
若是手动起的,要加这个参数再重启。"
                    .to_string()
            }
            else {
                format!("取指标失败: {s}")
            }
        })?;
    let body = r
        .body_mut()
        .read_to_string()
        .map_err(|e| format!("读响应失败: {e}"))?;
    let lines: Vec<String> = body
        .lines()
        .filter(|l| !l.starts_with('#') && l.to_ascii_lowercase().contains("token"))
        .map(|l| l.trim().to_string())
        .collect();
    Ok(lines)
}
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn payload_carries_role_and_content() {
        let msgs = vec![Msg::system(PERSONA), Msg::user("你好")];
        let p = build_payload("nfarms-local", &msgs, 128);
        assert_eq!(p["model"], "nfarms-local");
        assert_eq!(p["messages"][0]["role"], "system");
        assert_eq!(p["messages"][1]["content"], "你好");
        assert_eq!(p["stream"], false);
        assert_eq!(p["max_tokens"], 128);
    }

    #[test]
    fn parses_a_normal_reply() {
        let body = r#"{"choices":[{"message":{"role":"assistant","content":"  你好呀  "}}]}"#;
        assert_eq!(parse_reply(body).unwrap(), "你好呀");
    }

    /// 服务端的 `error` 字段要被认出来,而不是掉进「choices 为空」那条错里 ——
    /// 后者的报错和真实原因无关,会把人带偏。
    #[test]
    fn surfaces_the_server_side_error_message() {
        let body = r#"{"error":{"message":"request exceeds context size","type":"exceed_context_size_error"}}"#;
        let e = parse_reply(body).unwrap_err();
        assert!(e.contains("exceeds context"), "实际: {e}");
    }

    #[test]
    fn rejects_a_body_without_choices() {
        assert!(parse_reply("{}").is_err());
        assert!(parse_reply("not json").is_err());
    }

    /// 空回复不算成功 —— 界面上显示一个空气泡比显示一句错误更让人困惑。
    #[test]
    fn empty_content_is_an_error_not_an_empty_answer() {
        let body = r#"{"choices":[{"message":{"content":"   "}}]}"#;
        assert!(parse_reply(body).is_err());
    }

    #[test]
    fn recognises_connection_refused_in_the_various_spellings() {
        assert!(looks_like_connection_refused("Connection refused (os error 10061)"));
        assert!(looks_like_connection_refused(
            "No connection could be made because the target machine actively refused it"
        ));
        assert!(!looks_like_connection_refused("timed out"));
    }

    /// 对着一个**确定没人监听**的端口打一次,断言错误里带的是「服务没在跑」那句。
    ///
    /// 这条不依赖外部服务:它要验的就是**连不上时的提示对不对**。
    #[test]
    fn asking_a_dead_port_explains_that_the_server_is_not_running() {
        // 59999 不在常规分配范围,基本可以确定没人监听
        let r = ask("http://127.0.0.1:59999/v1", "m", &[Msg::user("hi")], 16);
        let e = r.unwrap_err();
        assert!(
            e.contains("没在跑"),
            "连不上时应当提示去启动服务,实际: {e}"
        );
    }

    /// 守的是**「`sd-cli` 卡住时 `draw` 会自己收场」**这件事 —— 出图线程
    /// 一旦在里面无限等下去,界面上就是永久转圈,而且之后的出图请求会被
    /// 静默丢掉,只能重启程序。
    ///
    /// 假 `sd-cli` 是个批处理:参数一个不看,自己 `ping` 约 7 秒再退出。故意用
    /// **有限**的时长,是为了万一超时被拿掉(退回 `output()` / `wait()`),
    /// 这条测试会在约 7 秒后**失败**,而不是把整个测试进程挂死。
    #[cfg(windows)]
    #[test]
    fn draw_gives_up_on_a_hung_sd_cli_instead_of_waiting_forever() {
        let dir = std::env::temp_dir().join(format!("nfarms-draw-hang-{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        let fake = dir.join("fake-sd-cli.cmd");
        // 先 `cd` 到 %TEMP%:被留了一下的 `ping` 会把工作目录占住,否则这个目录删不掉
        std::fs::write(
            &fake,
            "@echo off\r\ncd /d \"%TEMP%\"\r\nping -n 8 127.0.0.1 >nul\r\n",
        )
        .unwrap();

        let out = dir.join("draw.png");
        let t0 = std::time::Instant::now();
        let e =
            draw_with_timeout("卡住", &out, &dir, &fake, 64, 64, Duration::from_millis(500))
                .unwrap_err();

        assert!(e.contains("超时"), "子进程不退出时应当报「出图超时」,实际: {e}");
        // 超时给的是 500 ms,却等了 5 秒以上 —— 说明等的其实是子进程自己退出,
        // 也就是超时根本没生效(上面的断言此时也会因为报的是别的错而失败)。
        assert!(
            t0.elapsed() < Duration::from_secs(5),
            "没有在超时后立刻收场,等了 {:?}",
            t0.elapsed()
        );
        // 收拾现场而已:杀完进程到目录句柄真的放掉之间有一点时间差,删不掉就
        // 重试两下 —— 删不掉也不该让这条测试失败(失败信息会指向完全无关的地方)。
        for _ in 0..5 {
            if std::fs::remove_dir_all(&dir).is_ok() {
                break;
            }
            std::thread::sleep(Duration::from_millis(100));
        }
    }
}