@hej / sigit / commits / a58ff6e

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
@@ -4,7 +4,10 @@ name: CI
4 # Splits formatting, clippy, and tests into separate jobs for clearer reporting
5 # across supported desktop targets.
6
7 -on: push
7 +on:
8 + push:
9 + pull_request:
10 + workflow_dispatch:
11
12 env:
13 CARGO_TERM_COLOR: always
src/chat.rs
+97 -1
@@ -30,6 +30,44 @@ use ratatui::{
30 use tokio::sync::mpsc;
31 use tokio::time::{Duration, Instant, interval};
32
33 +// ── Think-block stripping ─────────────────────────────────────────────────────
34 +
35 +/// Strip `<think>…</think>` blocks from a model response.
36 +///
37 +/// Qwen 3 models emit `<think>…</think>` before the real answer. This
38 +/// function separates the thinking content from the visible reply so the
39 +/// UI can render them differently (dimmed / collapsed).
40 +///
41 +/// Returns `(thinking_text, visible_reply)`. Either may be empty.
42 +pub(crate) fn strip_think_blocks(raw: &str) -> (String, String) {
43 + let mut thinking = String::new();
44 + let mut remainder = raw;
45 +
46 + while let Some(start) = remainder.find("<think>") {
47 + // Text before <think> is visible.
48 + let before = &remainder[..start];
49 + if let Some(end) = remainder[start..].find("</think>") {
50 + let block = &remainder[start + 7..start + end];
51 + thinking.push_str(block.trim());
52 + remainder = &remainder[start + end + 8..];
53 + // Prepend any text before <think> to the leftover.
54 + if !before.trim().is_empty() {
55 + // Unusual — text before <think>. Keep it visible.
56 + let mut combined = before.to_string();
57 + combined.push_str(remainder);
58 + return (thinking, combined.trim().to_string());
59 + }
60 + } else {
61 + // Unclosed <think> — treat rest as thinking (model ran out of tokens).
62 + thinking.push_str(remainder[start + 7..].trim());
63 + remainder = before;
64 + break;
65 + }
66 + }
67 +
68 + (thinking, remainder.trim().to_string())
69 +}
70 +
71 // ── Message types ─────────────────────────────────────────────────────────────
72
73 #[derive(Clone, Copy, PartialEq, Eq)]
@@ -44,6 +82,8 @@ enum Role {
82 struct ChatMessage {
83 role: Role,
84 text: String,
85 + /// Extracted `<think>…</think>` content, if any (Qwen 3 reasoning).
86 + think_block: Option<String>,
87 }
88
89 impl ChatMessage {
@@ -51,13 +91,17 @@ impl ChatMessage {
91 Self {
92 role: Role::User,
93 text: text.into(),
94 + think_block: None,
95 }
96 }
97
98 fn assistant(text: impl Into<String>) -> Self {
99 + let raw = text.into();
100 + let (think, visible) = strip_think_blocks(&raw);
101 Self {
102 role: Role::Assistant,
60 - text: text.into(),
103 + text: visible,
104 + think_block: if think.is_empty() { None } else { Some(think) },
105 }
106 }
107
@@ -65,6 +109,7 @@ impl ChatMessage {
109 Self {
110 role: Role::System,
111 text: text.into(),
112 + think_block: None,
113 }
114 }
115
@@ -72,6 +117,7 @@ impl ChatMessage {
117 Self {
118 role: Role::Banner,
119 text: text.into(),
120 + think_block: None,
121 }
122 }
123 }
@@ -844,6 +890,24 @@ fn render_chat_message<'a>(lines: &mut Vec<Line<'a>>, msg: &ChatMessage) {
890 }
891 }
892 Role::Assistant => {
893 + // Show thinking block dimmed if present.
894 + if let Some(ref think) = msg.think_block {
895 + let think_summary = if think.len() > 120 {
896 + format!("{}…", &think[..120])
897 + } else {
898 + think.clone()
899 + };
900 + lines.push(Line::from(vec![
901 + Span::styled("💭 ", Style::default().fg(Color::DarkGray)),
902 + Span::styled(
903 + think_summary,
904 + Style::default()
905 + .fg(Color::DarkGray)
906 + .add_modifier(Modifier::ITALIC),
907 + ),
908 + ]));
909 + }
910 +
911 for (i, segment) in text_lines.iter().enumerate() {
912 let mut spans = Vec::new();
913 if i == 0 {
@@ -1509,3 +1573,35 @@ async fn event_loop<B: ratatui::backend::Backend>(
1573
1574 Ok(())
1575 }
1576 +
1577 +#[cfg(test)]
1578 +mod tests {
1579 + use super::strip_think_blocks;
1580 +
1581 + #[test]
1582 + fn strip_think_blocks_separates_thinking_and_visible_reply() {
1583 + let raw = "<think>I should inspect the code first.</think>Here is the fix.";
1584 + let (thinking, visible) = strip_think_blocks(raw);
1585 +
1586 + assert_eq!(thinking, "I should inspect the code first.");
1587 + assert_eq!(visible, "Here is the fix.");
1588 + }
1589 +
1590 + #[test]
1591 + fn strip_think_blocks_handles_unclosed_think_block() {
1592 + let raw = "<think>I am still reasoning about the bug";
1593 + let (thinking, visible) = strip_think_blocks(raw);
1594 +
1595 + assert_eq!(thinking, "I am still reasoning about the bug");
1596 + assert_eq!(visible, "");
1597 + }
1598 +
1599 + #[test]
1600 + fn strip_think_blocks_leaves_plain_text_untouched() {
1601 + let raw = "No hidden reasoning here.";
1602 + let (thinking, visible) = strip_think_blocks(raw);
1603 +
1604 + assert_eq!(thinking, "");
1605 + assert_eq!(visible, "No hidden reasoning here.");
1606 + }
1607 +}
src/main.rs
+4 -1
@@ -1120,7 +1120,10 @@ impl Agent for SiGitAgent {
1120 String::new()
1121 }
1122 } else {
1123 - reply_text
1123 + // Strip Qwen 3 `<think>…</think>` blocks — the editor doesn't
1124 + // need to see internal reasoning tokens.
1125 + let (_think, visible) = chat::strip_think_blocks(&reply_text);
1126 + visible
1127 };
1128
1129 if !final_text.is_empty() {
src/models.rs
+2
@@ -44,6 +44,7 @@ pub(crate) fn model_id_to_config(model_id: &str) -> Option<GgufModelConfig> {
44 Some(match model_id {
45 "bartowski/Qwen_Qwen3-4B-GGUF" => GgufModelConfig::qwen3_4b(),
46 "bartowski/Qwen_Qwen3-8B-GGUF" => GgufModelConfig::qwen3_8b(),
47 + "bartowski/Qwen_Qwen3-14B-GGUF" => GgufModelConfig::qwen3_14b(),
48 "bartowski/Qwen_Qwen3-1.7B-GGUF" => GgufModelConfig::qwen3_1_7b(),
49 "bartowski/Qwen2.5-3B-Instruct-GGUF" => GgufModelConfig::qwen25_3b(),
50 "bartowski/Qwen2.5-1.5B-Instruct-GGUF" => GgufModelConfig::qwen25_1_5b(),
@@ -61,6 +62,7 @@ fn is_tool_calling(model_id: &str) -> bool {
62 model_id,
63 "bartowski/Qwen_Qwen3-4B-GGUF"
64 | "bartowski/Qwen_Qwen3-8B-GGUF"
65 + | "bartowski/Qwen_Qwen3-14B-GGUF"
66 | "bartowski/Qwen_Qwen3-1.7B-GGUF"
67 | "bartowski/Qwen2.5-Coder-7B-Instruct-GGUF"
68 )