Support Qwen 3 <think> blocks and add 14B model
- Parse and render Qwen 3 `<think>…</think>` reasoning blocks in chat - Add Qwen_Qwen3-14B-GGUF model support - Update CI to trigger on pull_request and workflow_dispatch
paydii committed
Apr 26, 2026 at 07:47 UTC
a58ff6ef1a7708489b132ba0bed174e1ff049942
4 files changed
+107
-3
.github/workflows/ci.yml
+4
-1
index 6cebdce..1d17ef7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -4,7 +4,10 @@ name: CI
# Splits formatting, clippy, and tests into separate jobs for clearer reporting
# across supported desktop targets.
-on: push
+on:
+ push:
+ pull_request:
+ workflow_dispatch:
env:
CARGO_TERM_COLOR: always
src/chat.rs
+97
-1
index 5148b6b..108c092 100644
--- a/src/chat.rs
+++ b/src/chat.rs
@@ -30,6 +30,44 @@ use ratatui::{
use tokio::sync::mpsc;
use tokio::time::{Duration, Instant, interval};
+// ── Think-block stripping ─────────────────────────────────────────────────────
+
+/// Strip `<think>…</think>` blocks from a model response.
+///
+/// Qwen 3 models emit `<think>…</think>` before the real answer. This
+/// function separates the thinking content from the visible reply so the
+/// UI can render them differently (dimmed / collapsed).
+///
+/// Returns `(thinking_text, visible_reply)`. Either may be empty.
+pub(crate) fn strip_think_blocks(raw: &str) -> (String, String) {
+ let mut thinking = String::new();
+ let mut remainder = raw;
+
+ while let Some(start) = remainder.find("<think>") {
+ // Text before <think> is visible.
+ let before = &remainder[..start];
+ if let Some(end) = remainder[start..].find("</think>") {
+ let block = &remainder[start + 7..start + end];
+ thinking.push_str(block.trim());
+ remainder = &remainder[start + end + 8..];
+ // Prepend any text before <think> to the leftover.
+ if !before.trim().is_empty() {
+ // Unusual — text before <think>. Keep it visible.
+ let mut combined = before.to_string();
+ combined.push_str(remainder);
+ return (thinking, combined.trim().to_string());
+ }
+ } else {
+ // Unclosed <think> — treat rest as thinking (model ran out of tokens).
+ thinking.push_str(remainder[start + 7..].trim());
+ remainder = before;
+ break;
+ }
+ }
+
+ (thinking, remainder.trim().to_string())
+}
+
// ── Message types ─────────────────────────────────────────────────────────────
#[derive(Clone, Copy, PartialEq, Eq)]
@@ -44,6 +82,8 @@ enum Role {
struct ChatMessage {
role: Role,
text: String,
+ /// Extracted `<think>…</think>` content, if any (Qwen 3 reasoning).
+ think_block: Option<String>,
}
impl ChatMessage {
@@ -51,13 +91,17 @@ impl ChatMessage {
Self {
role: Role::User,
text: text.into(),
+ think_block: None,
}
}
fn assistant(text: impl Into<String>) -> Self {
+ let raw = text.into();
+ let (think, visible) = strip_think_blocks(&raw);
Self {
role: Role::Assistant,
- text: text.into(),
+ text: visible,
+ think_block: if think.is_empty() { None } else { Some(think) },
}
}
@@ -65,6 +109,7 @@ impl ChatMessage {
Self {
role: Role::System,
text: text.into(),
+ think_block: None,
}
}
@@ -72,6 +117,7 @@ impl ChatMessage {
Self {
role: Role::Banner,
text: text.into(),
+ think_block: None,
}
}
}
@@ -844,6 +890,24 @@ fn render_chat_message<'a>(lines: &mut Vec<Line<'a>>, msg: &ChatMessage) {
}
}
Role::Assistant => {
+ // Show thinking block dimmed if present.
+ if let Some(ref think) = msg.think_block {
+ let think_summary = if think.len() > 120 {
+ format!("{}…", &think[..120])
+ } else {
+ think.clone()
+ };
+ lines.push(Line::from(vec![
+ Span::styled("💭 ", Style::default().fg(Color::DarkGray)),
+ Span::styled(
+ think_summary,
+ Style::default()
+ .fg(Color::DarkGray)
+ .add_modifier(Modifier::ITALIC),
+ ),
+ ]));
+ }
+
for (i, segment) in text_lines.iter().enumerate() {
let mut spans = Vec::new();
if i == 0 {
@@ -1509,3 +1573,35 @@ async fn event_loop<B: ratatui::backend::Backend>(
Ok(())
}
+
+#[cfg(test)]
+mod tests {
+ use super::strip_think_blocks;
+
+ #[test]
+ fn strip_think_blocks_separates_thinking_and_visible_reply() {
+ let raw = "<think>I should inspect the code first.</think>Here is the fix.";
+ let (thinking, visible) = strip_think_blocks(raw);
+
+ assert_eq!(thinking, "I should inspect the code first.");
+ assert_eq!(visible, "Here is the fix.");
+ }
+
+ #[test]
+ fn strip_think_blocks_handles_unclosed_think_block() {
+ let raw = "<think>I am still reasoning about the bug";
+ let (thinking, visible) = strip_think_blocks(raw);
+
+ assert_eq!(thinking, "I am still reasoning about the bug");
+ assert_eq!(visible, "");
+ }
+
+ #[test]
+ fn strip_think_blocks_leaves_plain_text_untouched() {
+ let raw = "No hidden reasoning here.";
+ let (thinking, visible) = strip_think_blocks(raw);
+
+ assert_eq!(thinking, "");
+ assert_eq!(visible, "No hidden reasoning here.");
+ }
+}
src/main.rs
+4
-1
index c8b0db7..83f014c 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1120,7 +1120,10 @@ impl Agent for SiGitAgent {
String::new()
}
} else {
- reply_text
+ // Strip Qwen 3 `<think>…</think>` blocks — the editor doesn't
+ // need to see internal reasoning tokens.
+ let (_think, visible) = chat::strip_think_blocks(&reply_text);
+ visible
};
if !final_text.is_empty() {
src/models.rs
+2
index 5713a17..3f4d4c7 100644
--- a/src/models.rs
+++ b/src/models.rs
@@ -44,6 +44,7 @@ pub(crate) fn model_id_to_config(model_id: &str) -> Option<GgufModelConfig> {
Some(match model_id {
"bartowski/Qwen_Qwen3-4B-GGUF" => GgufModelConfig::qwen3_4b(),
"bartowski/Qwen_Qwen3-8B-GGUF" => GgufModelConfig::qwen3_8b(),
+ "bartowski/Qwen_Qwen3-14B-GGUF" => GgufModelConfig::qwen3_14b(),
"bartowski/Qwen_Qwen3-1.7B-GGUF" => GgufModelConfig::qwen3_1_7b(),
"bartowski/Qwen2.5-3B-Instruct-GGUF" => GgufModelConfig::qwen25_3b(),
"bartowski/Qwen2.5-1.5B-Instruct-GGUF" => GgufModelConfig::qwen25_1_5b(),
@@ -61,6 +62,7 @@ fn is_tool_calling(model_id: &str) -> bool {
model_id,
"bartowski/Qwen_Qwen3-4B-GGUF"
| "bartowski/Qwen_Qwen3-8B-GGUF"
+ | "bartowski/Qwen_Qwen3-14B-GGUF"
| "bartowski/Qwen_Qwen3-1.7B-GGUF"
| "bartowski/Qwen2.5-Coder-7B-Instruct-GGUF"
)