Files
xserv/crates/xserv-model/src/bin/xserv-chat.rs
Gahow Wang c2362df1f1 fix(xserv-chat): UTF-8/CJK-aware line input
Cooked-mode read_line() left line editing to the terminal, so Backspace on a
multi-byte 汉字/かな/한글 deleted a byte (or behaved inconsistently across TTYs).
Replace with a raw-mode reader (libc termios): Backspace pops a whole char,
multi-byte input is reassembled from its continuation bytes, and a full-line
redraw renders double-width glyphs correctly. Non-TTY input falls back to a
plain read; raw mode is restored after each line. libc is already a locked
transitive dep, so this builds offline.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 11:36:54 +08:00

550 lines
17 KiB
Rust

use std::io::{self, IsTerminal, Read, Write};
use std::path::PathBuf;
use xserv_model::{loader, sample, ModelConfig, PagedKVCache, Qwen3, SamplingParams, BLOCK_SIZE};
use xserv_tensor::{DType, Device};
use xserv_tokenizer::Tokenizer;
const SLOT: usize = 0;
struct CliOptions {
model_dir: PathBuf,
max_tokens: usize,
max_seq_len: usize,
sampling: SamplingParams,
system_prompt: Option<String>,
enable_thinking: bool,
color: bool,
}
enum Finish {
Stop { token_id: u32 },
Length,
}
enum Line {
Text(String),
Eof,
}
/// RAII terminal raw-mode guard. Disables canonical mode + echo (keeps output
/// post-processing and signals), so we read keystrokes ourselves and edit the
/// line UTF-8-aware. Restores the original termios on drop.
struct RawMode {
orig: libc::termios,
}
impl RawMode {
fn enable() -> Option<Self> {
unsafe {
let mut orig: libc::termios = std::mem::zeroed();
if libc::tcgetattr(libc::STDIN_FILENO, &mut orig) != 0 {
return None;
}
let mut raw = orig;
raw.c_lflag &= !(libc::ICANON | libc::ECHO);
raw.c_cc[libc::VMIN as usize] = 1;
raw.c_cc[libc::VTIME as usize] = 0;
if libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &raw) != 0 {
return None;
}
Some(RawMode { orig })
}
}
}
impl Drop for RawMode {
fn drop(&mut self) {
unsafe {
libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &self.orig);
}
}
}
/// Read one line with UTF-8/CJK-aware editing. In a TTY this enters raw mode and
/// handles keystrokes so Backspace deletes a whole character (not a byte), and
/// multi-byte input (汉字/日本語/한글) renders correctly. Non-TTY (piped) input
/// falls back to a plain cooked read.
fn read_line_edited(prompt: &str) -> Line {
let cooked = || -> Line {
print!("{prompt}");
io::stdout().flush().ok();
let mut s = String::new();
match io::stdin().read_line(&mut s) {
Ok(0) | Err(_) => Line::Eof,
Ok(_) => Line::Text(s),
}
};
if !io::stdin().is_terminal() {
return cooked();
}
let Some(_raw) = RawMode::enable() else {
return cooked();
};
// Single-line editor: on every edit, rewrite the whole line so the terminal
// renders correct (incl. double-width CJK) glyphs; \x1b[K clears leftovers.
let redraw = |buf: &str| {
print!("\r{prompt}{buf}\x1b[K");
io::stdout().flush().ok();
};
let mut buf = String::new();
redraw(&buf);
let mut stdin = io::stdin().lock();
let mut byte = [0u8; 1];
loop {
if stdin.read(&mut byte).unwrap_or(0) == 0 {
// EOF on the stream.
if buf.is_empty() {
return Line::Eof;
}
break;
}
match byte[0] {
b'\r' | b'\n' => {
println!();
break;
}
0x7f | 0x08 => {
// Backspace: drop one whole char (String::pop is char-aware).
buf.pop();
redraw(&buf);
}
0x04 => {
// Ctrl-D: EOF only when the line is empty.
if buf.is_empty() {
return Line::Eof;
}
}
0x1b => {
// Escape sequence (arrows, etc.): consume and ignore the 2 bytes
// of a typical CSI sequence so they don't land in the buffer.
let mut seq = [0u8; 2];
let _ = stdin.read(&mut seq);
}
b if b < 0x20 => { /* other control bytes: ignore */ }
b if b < 0x80 => {
buf.push(b as char);
redraw(&buf);
}
b => {
// UTF-8 multi-byte: read the continuation bytes for this char.
let extra = if b >= 0xF0 { 3 } else if b >= 0xE0 { 2 } else { 1 };
let mut bytes = vec![b];
let mut cont = [0u8; 1];
let mut ok = true;
for _ in 0..extra {
if stdin.read(&mut cont).unwrap_or(0) == 0 {
ok = false;
break;
}
bytes.push(cont[0]);
}
if ok {
if let Ok(s) = std::str::from_utf8(&bytes) {
buf.push_str(s);
redraw(&buf);
}
}
}
}
}
Line::Text(buf)
}
fn main() {
let opts = parse_args();
xserv_cuda::device::set_device(0).unwrap();
let info = xserv_cuda::device::device_info(0).unwrap();
eprintln!(
"GPU: {} ({} MB free)",
info.name,
info.free_memory / 1024 / 1024
);
let config = ModelConfig::from_file(&opts.model_dir.join("config.json"));
let model_type = config.model_type.as_deref().unwrap_or("unknown");
if !model_type.contains("qwen") {
eprintln!("xserv-chat currently supports Qwen-style ChatML models only; got model_type={model_type}");
std::process::exit(2);
}
let max_seq_len = opts.max_seq_len.min(config.max_seq_len()).max(1);
eprintln!(
"Model: {model_type}, layers={}, hidden={}, heads={}/{} kv, vocab={}, max_seq_len={}",
config.num_layers(),
config.hidden(),
config.num_heads(),
config.num_kv_heads(),
config.vocab_size,
max_seq_len
);
eprintln!("Loading weights...");
let weights = loader::load_model_dir(&opts.model_dir, Device::Cuda(0));
eprintln!("Loaded {} tensors", weights.len());
let model = Qwen3::from_weights(config.clone(), weights);
let tokenizer = Tokenizer::from_file(&opts.model_dir.join("tokenizer.json"));
let mut cache = new_paged_cache(&config, max_seq_len);
cache.register_sequence(SLOT).expect("register chat slot");
let use_color = opts.color && io::stdout().is_terminal();
eprintln!("Ready (paged KV cache, persistent chat slot).");
eprintln!("Commands: /exit, /quit, /clear\n");
loop {
let line = match read_line_edited("user> ") {
Line::Eof => break,
Line::Text(s) => s,
};
let input = line.trim();
if input.is_empty() {
continue;
}
match input {
"/exit" | "/quit" | "exit" | "quit" => break,
"/clear" => {
cache.free_sequence(SLOT);
cache.register_sequence(SLOT).expect("register chat slot");
eprintln!("history and KV cache cleared");
continue;
}
"/help" => {
print_help();
continue;
}
_ => {}
}
let include_system = cache.seq_len(SLOT) == 0;
let prompt = build_turn_prompt(
opts.system_prompt.as_deref(),
include_system,
input,
opts.enable_thinking,
);
let prompt_tokens = tokenizer.encode(&prompt);
if prompt_tokens.is_empty() {
continue;
}
let used = cache.seq_len(SLOT);
let remaining = max_seq_len.saturating_sub(used);
if prompt_tokens.len() >= remaining {
eprintln!(
"context full: {used}/{max_seq_len} tokens used, new turn needs {} tokens; use /clear",
prompt_tokens.len()
);
continue;
}
let max_new_tokens = opts.max_tokens.min(remaining - prompt_tokens.len());
print!("assistant> ");
io::stdout().flush().unwrap();
let finish = generate_with_paged_cache(
&model,
&mut cache,
&tokenizer,
&prompt_tokens,
&opts.sampling,
max_new_tokens,
use_color,
);
match finish {
Finish::Stop { token_id } => {
append_after_stop(&model, &mut cache, &tokenizer, max_seq_len, token_id);
}
Finish::Length => {
append_text_to_cache(&model, &mut cache, &tokenizer, max_seq_len, "<|im_end|>\n");
}
}
println!();
}
}
fn parse_args() -> CliOptions {
let args: Vec<String> = std::env::args().skip(1).collect();
if args.is_empty() || args.iter().any(|a| a == "--help" || a == "-h") {
print_usage_and_exit(0);
}
let mut model_dir = None;
let mut max_tokens = 256usize;
let mut max_seq_len = 2048usize;
let mut temperature = 0.0f32;
let mut top_k = 0usize;
let mut top_p = 1.0f32;
let mut system_prompt = None;
let mut enable_thinking = false;
let mut color = true;
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"-m" | "--model" => {
i += 1;
model_dir = args.get(i).map(PathBuf::from);
}
"--max-tokens" => {
i += 1;
max_tokens = parse_value(&args, i, "--max-tokens");
}
"--max-seq-len" => {
i += 1;
max_seq_len = parse_value(&args, i, "--max-seq-len");
}
"--temperature" => {
i += 1;
temperature = parse_value(&args, i, "--temperature");
}
"--top-k" => {
i += 1;
top_k = parse_value(&args, i, "--top-k");
}
"--top-p" => {
i += 1;
top_p = parse_value(&args, i, "--top-p");
}
"--system" => {
i += 1;
system_prompt = args.get(i).cloned();
if system_prompt.is_none() {
eprintln!("missing value for --system");
std::process::exit(2);
}
}
"--think" => {
enable_thinking = true;
}
"--no-color" => {
color = false;
}
arg if arg.starts_with('-') => {
eprintln!("unknown option: {arg}");
print_usage_and_exit(2);
}
arg => {
if model_dir.is_some() {
eprintln!("unexpected extra argument: {arg}");
print_usage_and_exit(2);
}
model_dir = Some(PathBuf::from(arg));
}
}
i += 1;
}
CliOptions {
model_dir: model_dir.unwrap_or_else(|| {
eprintln!("missing model directory");
print_usage_and_exit(2);
}),
max_tokens: max_tokens.max(1),
max_seq_len: max_seq_len.max(1),
sampling: SamplingParams {
temperature,
top_k,
top_p,
},
system_prompt,
enable_thinking,
color,
}
}
fn parse_value<T: std::str::FromStr>(args: &[String], i: usize, name: &str) -> T {
args.get(i).and_then(|s| s.parse().ok()).unwrap_or_else(|| {
eprintln!("invalid or missing value for {name}");
std::process::exit(2);
})
}
fn print_usage_and_exit(code: i32) -> ! {
eprintln!(
"Usage: xserv-chat <model-dir> [options]\n\
\n\
Options:\n\
\t-m, --model DIR Model directory\n\
\t--max-tokens N Max generated tokens per turn (default: 256)\n\
\t--max-seq-len N Persistent KV context length (default: 2048)\n\
\t--temperature F Sampling temperature, 0 = greedy (default: 0)\n\
\t--top-k N Top-k sampling, 0 = disabled (default: 0)\n\
\t--top-p F Top-p sampling (default: 1.0)\n\
\t--system TEXT System prompt for the first turn after start or /clear\n\
\t--think Let Qwen3 emit thinking; rendered gray on terminals\n\
\t--no-color Disable ANSI color for thinking output\n\
\t-h, --help Show this help"
);
std::process::exit(code);
}
fn print_help() {
eprintln!("Commands:");
eprintln!(" /clear clear chat history and free/recreate the paged KV slot");
eprintln!(" /exit quit");
eprintln!(" /quit quit");
}
fn new_paged_cache(config: &ModelConfig, max_seq_len: usize) -> PagedKVCache {
let max_blocks_per_seq = (max_seq_len + BLOCK_SIZE - 1) / BLOCK_SIZE;
let total_blocks = (max_blocks_per_seq + 1).max(2);
// Single-slot interactive CLI: no swap pool (cpu_total_blocks = 0).
PagedKVCache::new(config, total_blocks, 0, 1, max_blocks_per_seq, DType::BF16, 0)
}
fn build_turn_prompt(
system: Option<&str>,
include_system: bool,
user_input: &str,
enable_thinking: bool,
) -> String {
let mut prompt = String::new();
if include_system {
if let Some(system) = system {
if !system.trim().is_empty() {
prompt.push_str("<|im_start|>system\n");
prompt.push_str(system.trim());
prompt.push_str("<|im_end|>\n");
}
}
}
prompt.push_str("<|im_start|>user\n");
prompt.push_str(user_input);
prompt.push_str("<|im_end|>\n");
prompt.push_str("<|im_start|>assistant\n");
if !enable_thinking {
prompt.push_str("<think>\n\n</think>\n\n");
}
prompt
}
fn generate_with_paged_cache(
model: &Qwen3,
cache: &mut PagedKVCache,
tokenizer: &Tokenizer,
prompt_tokens: &[u32],
sampling: &SamplingParams,
max_tokens: usize,
use_color: bool,
) -> Finish {
let logits = model.forward_prefill_paged(prompt_tokens, SLOT, cache);
let mut next = sample(&logits, sampling);
let mut decode_buffer = Vec::new();
let mut in_thinking = false;
for _ in 0..max_tokens {
let position = cache.seq_len(SLOT);
let logits = model.forward_decode_paged(&[next], &[position], &[SLOT], cache);
if is_stop_token(tokenizer, next) {
print_stream_text(
&tokenizer.flush_decode_stream(&mut decode_buffer),
in_thinking,
use_color,
);
io::stdout().flush().unwrap();
return Finish::Stop { token_id: next };
}
print_generated_token(
tokenizer,
next,
&mut decode_buffer,
&mut in_thinking,
use_color,
);
io::stdout().flush().unwrap();
next = sample(&logits, sampling);
}
print_stream_text(
&tokenizer.flush_decode_stream(&mut decode_buffer),
in_thinking,
use_color,
);
io::stdout().flush().unwrap();
Finish::Length
}
fn append_after_stop(
model: &Qwen3,
cache: &mut PagedKVCache,
tokenizer: &Tokenizer,
max_seq_len: usize,
stop_token_id: u32,
) {
if tokenizer.special_token_id("<|im_end|>") == Some(stop_token_id) {
append_text_to_cache(model, cache, tokenizer, max_seq_len, "\n");
}
}
fn append_text_to_cache(
model: &Qwen3,
cache: &mut PagedKVCache,
tokenizer: &Tokenizer,
max_seq_len: usize,
text: &str,
) {
let tokens = tokenizer.encode(text);
if tokens.is_empty() || cache.seq_len(SLOT) + tokens.len() > max_seq_len {
return;
}
let _ = model.forward_prefill_paged(&tokens, SLOT, cache);
}
fn print_generated_token(
tokenizer: &Tokenizer,
token_id: u32,
decode_buffer: &mut Vec<u8>,
in_thinking: &mut bool,
use_color: bool,
) {
if tokenizer.special_token_id("<think>") == Some(token_id) {
print_stream_text(
&tokenizer.flush_decode_stream(decode_buffer),
*in_thinking,
use_color,
);
*in_thinking = true;
print_stream_text("<think>", true, use_color);
return;
}
if tokenizer.special_token_id("</think>") == Some(token_id) {
print_stream_text(
&tokenizer.flush_decode_stream(decode_buffer),
*in_thinking,
use_color,
);
print_stream_text("</think>", true, use_color);
*in_thinking = false;
return;
}
let text = tokenizer.decode_token_stream(token_id, decode_buffer);
print_stream_text(&text, *in_thinking, use_color);
}
fn print_stream_text(text: &str, in_thinking: bool, use_color: bool) {
if text.is_empty() {
return;
}
if in_thinking && use_color {
print!("\x1b[90m{text}\x1b[0m");
} else {
print!("{text}");
}
}
fn is_stop_token(tokenizer: &Tokenizer, token_id: u32) -> bool {
tokenizer.eos_token_id() == Some(token_id)
|| tokenizer.special_token_id("<|im_end|>") == Some(token_id)
|| tokenizer.special_token_id("<|endoftext|>") == Some(token_id)
|| tokenizer.special_token_id("<|end_of_text|>") == Some(token_id)
}