onw 1.1.2: chat auto-scroll stops when scrolled up, jump-to-latest button
Browse files- CHANGELOG.md +5 -1
- CHANGELOG_en.md +5 -1
- MANUAL.md +1 -0
- MANUAL_en.md +2 -1
- onw/__init__.py +1 -1
- onw/i18n.json +4 -0
- onw/web/index.html +35 -5
- pyproject.toml +1 -1
CHANGELOG.md
CHANGED
|
@@ -5,7 +5,11 @@
|
|
| 5 |
日付は日本時間です。各版は Hugging Face の `ryugyosoft/onw` にタグ(`v0.4.2` 以降)付きで残っています。
|
| 6 |
onw ウィンドウは、新しい版があるとこのファイルから変更点を表示します(`## v…` の見出しで区切っています)。
|
| 7 |
|
| 8 |
-
## v1.1.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
- NF4 版に **Qwen3.5-9B (NF4)** を追加(Lunar Lake 以降専用)。Lunar Lake(16 GB)で通常版と比べて、生成 7.1 → 9.4 tok/s、
|
| 10 |
プロンプト処理 約 2.3 倍速、使用メモリ 6.6 → 5.1 GB(約 2.4 割減)。CPU で元のモデルと比べた精度はほぼ同等です。
|
| 11 |
- チャット画面:Gemma・Qwen 系(Ornith・NeoHorse を含む)・gpt-oss では、モデルの推奨値(温度・Top P・Top K・出現ペナルティ)
|
|
|
|
| 5 |
日付は日本時間です。各版は Hugging Face の `ryugyosoft/onw` にタグ(`v0.4.2` 以降)付きで残っています。
|
| 6 |
onw ウィンドウは、新しい版があるとこのファイルから変更点を表示します(`## v…` の見出しで区切っています)。
|
| 7 |
|
| 8 |
+
## v1.1.2 (2026-10-06)
|
| 9 |
+
- チャット画面:回答の途中で上にスクロールすると、自動で一番下まで戻らないようにしました(これまでは回答が流れている間、
|
| 10 |
+
開いた「思考」欄を閉じたり、上の会話を読んだりできませんでした)。下にいないときは右下に「↓ 最新へ」ボタンが出て、
|
| 11 |
+
押すと最新の位置に戻り、また回答に追従します。新しいメッセージを送ったときも追従に戻ります。
|
| 12 |
+
|
| 13 |
- NF4 版に **Qwen3.5-9B (NF4)** を追加(Lunar Lake 以降専用)。Lunar Lake(16 GB)で通常版と比べて、生成 7.1 → 9.4 tok/s、
|
| 14 |
プロンプト処理 約 2.3 倍速、使用メモリ 6.6 → 5.1 GB(約 2.4 割減)。CPU で元のモデルと比べた精度はほぼ同等です。
|
| 15 |
- チャット画面:Gemma・Qwen 系(Ornith・NeoHorse を含む)・gpt-oss では、モデルの推奨値(温度・Top P・Top K・出現ペナルティ)
|
CHANGELOG_en.md
CHANGED
|
@@ -4,7 +4,11 @@
|
|
| 4 |
|
| 5 |
Dates are JST. Every version is on Hugging Face `ryugyosoft/onw`, tagged from `v0.4.2` on.
|
| 6 |
|
| 7 |
-
## v1.1.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
- A new NF4 build: **Qwen3.5-9B (NF4)** (Lunar Lake and newer only). Against the regular build on Lunar Lake (16 GB):
|
| 9 |
decode 7.1 -> 9.4 tok/s, prompts ~2.3x faster, memory 6.6 -> 5.1 GB (~24% less), about the same accuracy on the CPU.
|
| 10 |
- Chat page: for Gemma, the Qwen family (Ornith and NeoHorse included) and gpt-oss the model's recommended temperature,
|
|
|
|
| 4 |
|
| 5 |
Dates are JST. Every version is on Hugging Face `ryugyosoft/onw`, tagged from `v0.4.2` on.
|
| 6 |
|
| 7 |
+
## v1.1.2 (2026-10-06)
|
| 8 |
+
- Chat page: scrolling up while an answer is being written no longer jumps back to the bottom (opening or closing a
|
| 9 |
+
"Thinking" box or reading above was impossible while it streamed). Away from the bottom, a "↓ Latest" button appears at
|
| 10 |
+
the bottom right; it jumps back and follows the answer again, as does sending a new message.
|
| 11 |
+
|
| 12 |
- A new NF4 build: **Qwen3.5-9B (NF4)** (Lunar Lake and newer only). Against the regular build on Lunar Lake (16 GB):
|
| 13 |
decode 7.1 -> 9.4 tok/s, prompts ~2.3x faster, memory 6.6 -> 5.1 GB (~24% less), about the same accuracy on the CPU.
|
| 14 |
- Chat page: for Gemma, the Qwen family (Ornith and NeoHorse included) and gpt-oss the model's recommended temperature,
|
MANUAL.md
CHANGED
|
@@ -87,6 +87,7 @@
|
|
| 87 |
「サーバー」タブかトレイの「チャット」で、ブラウザに開きます(`http://localhost:8000/chat`)。
|
| 88 |
|
| 89 |
- **送る**:メッセージを入れて Enter(改行は Shift+Enter)。回答中は「送信」が「停止」になり、押すとそこで止めます。
|
|
|
|
| 90 |
- **画像**:📎 で添付するか、画像を貼り付け(Ctrl+V)・ドロップします(画像を読めるモデルのとき)。
|
| 91 |
- **思考モード**:オンにすると、モデルが考えてから答えます。考えた内容は回答の上の「思考」欄に入り、クリックで開閉できます。
|
| 92 |
「常に考える」モデル(gpt-oss、llm-jp)はオフでも考え、その分も最大トークンに数えます。
|
|
|
|
| 87 |
「サーバー」タブかトレイの「チャット」で、ブラウザに開きます(`http://localhost:8000/chat`)。
|
| 88 |
|
| 89 |
- **送る**:メッセージを入れて Enter(改行は Shift+Enter)。回答中は「送信」が「停止」になり、押すとそこで止めます。
|
| 90 |
+
回答中に上へスクロールすると自動スクロールが止まり、右下の「↓ 最新へ」で最新の位置に戻れます。
|
| 91 |
- **画像**:📎 で添付するか、画像を貼り付け(Ctrl+V)・ドロップします(画像を読めるモデルのとき)。
|
| 92 |
- **思考モード**:オンにすると、モデルが考えてから答えます。考えた内容は回答の上の「思考」欄に入り、クリックで開閉できます。
|
| 93 |
「常に考える」モデル(gpt-oss、llm-jp)はオフでも考え、その分も最大トークンに数えます。
|
MANUAL_en.md
CHANGED
|
@@ -89,7 +89,8 @@ If loading fails, the state says "Load failed" with the reason.
|
|
| 89 |
|
| 90 |
"Chat" on the Server tab or in the tray opens it in the browser (`http://localhost:8000/chat`).
|
| 91 |
|
| 92 |
-
- **Send**: type and press Enter (Shift+Enter for a new line). While it answers, Send turns into Stop.
|
|
|
|
| 93 |
- **Images**: attach with 📎, or paste (Ctrl+V) or drop an image (models that read images).
|
| 94 |
- **Thinking mode**: the model thinks before it answers. The thinking goes in the "Thinking" box above the answer; click
|
| 95 |
it to open or close it. "Always" models (gpt-oss, llm-jp) think even when it is off, and that counts against the max
|
|
|
|
| 89 |
|
| 90 |
"Chat" on the Server tab or in the tray opens it in the browser (`http://localhost:8000/chat`).
|
| 91 |
|
| 92 |
+
- **Send**: type and press Enter (Shift+Enter for a new line). While it answers, Send turns into Stop. Scrolling up
|
| 93 |
+
while it answers stops the auto-scroll; "↓ Latest" at the bottom right jumps back.
|
| 94 |
- **Images**: attach with 📎, or paste (Ctrl+V) or drop an image (models that read images).
|
| 95 |
- **Thinking mode**: the model thinks before it answers. The thinking goes in the "Thinking" box above the answer; click
|
| 96 |
it to open or close it. "Always" models (gpt-oss, llm-jp) think even when it is off, and that counts against the max
|
onw/__init__.py
CHANGED
|
@@ -2,4 +2,4 @@
|
|
| 2 |
|
| 3 |
A small engine that runs LLMs entirely on the Intel NPU: static segment graphs + host-managed state.
|
| 4 |
"""
|
| 5 |
-
__version__ = "1.1.
|
|
|
|
| 2 |
|
| 3 |
A small engine that runs LLMs entirely on the Intel NPU: static segment graphs + host-managed state.
|
| 4 |
"""
|
| 5 |
+
__version__ = "1.1.2"
|
onw/i18n.json
CHANGED
|
@@ -57,6 +57,7 @@
|
|
| 57 |
"{0} 以上推奨(1 では同じ言葉を繰り返すことがあります)": "{0}+ recommended (at 1 it can repeat the same word)",
|
| 58 |
"{0}秒": "{0} s",
|
| 59 |
"{0}秒 · {1}トークン · 文脈 {2}{3}": "{0} s · {1} tokens · context {2}{3}",
|
|
|
|
| 60 |
"、": ", ",
|
| 61 |
"「{0}」を削除しますか?(元に戻せません)": "Delete “{0}”? (This cannot be undone.)",
|
| 62 |
"「更新」を押すと自動で入れ替えて再起動します。": "Choose \"Update\" to replace it and restart automatically.",
|
|
@@ -233,6 +234,7 @@
|
|
| 233 |
"最大": "Largest",
|
| 234 |
"最大トークン": "Max tokens",
|
| 235 |
"最大(メモリが足りない PC ではエキスパートを SSD から読みます)": "Largest (reads experts from the SSD on PCs short of memory)",
|
|
|
|
| 236 |
"最新版です": "You're up to date",
|
| 237 |
"最終確認 {0}": "Last checked {0}",
|
| 238 |
"有効": "On",
|
|
@@ -350,6 +352,7 @@
|
|
| 350 |
"{0} 以上推奨(1 では同じ言葉を繰り返すことがあります)": "建议 {0} 以上(为 1 时可能重复同一个词)",
|
| 351 |
"{0}秒": "{0} 秒",
|
| 352 |
"{0}秒 · {1}トークン · 文脈 {2}{3}": "{0}秒 · {1} token · 上下文 {2}{3}",
|
|
|
|
| 353 |
"、": "、",
|
| 354 |
"「{0}」を削除しますか?(元に戻せません)": "删除“{0}”吗?(无法恢复)",
|
| 355 |
"「更新」を押すと自動で入れ替えて再起動します。": "点击“更新”即可自动替换并重启。",
|
|
@@ -526,6 +529,7 @@
|
|
| 526 |
"最大": "最大",
|
| 527 |
"最大トークン": "最大 token 数",
|
| 528 |
"最大(メモリが足りない PC ではエキスパートを SSD から読みます)": "最大(内存不足的电脑从 SSD 读取专家)",
|
|
|
|
| 529 |
"最新版です": "已是最新版本",
|
| 530 |
"最終確認 {0}": "上次检查 {0}",
|
| 531 |
"有効": "开启",
|
|
|
|
| 57 |
"{0} 以上推奨(1 では同じ言葉を繰り返すことがあります)": "{0}+ recommended (at 1 it can repeat the same word)",
|
| 58 |
"{0}秒": "{0} s",
|
| 59 |
"{0}秒 · {1}トークン · 文脈 {2}{3}": "{0} s · {1} tokens · context {2}{3}",
|
| 60 |
+
"↓ 最新へ": "↓ Latest",
|
| 61 |
"、": ", ",
|
| 62 |
"「{0}」を削除しますか?(元に戻せません)": "Delete “{0}”? (This cannot be undone.)",
|
| 63 |
"「更新」を押すと自動で入れ替えて再起動します。": "Choose \"Update\" to replace it and restart automatically.",
|
|
|
|
| 234 |
"最大": "Largest",
|
| 235 |
"最大トークン": "Max tokens",
|
| 236 |
"最大(メモリが足りない PC ではエキスパートを SSD から読みます)": "Largest (reads experts from the SSD on PCs short of memory)",
|
| 237 |
+
"最新へ": "Jump to the latest",
|
| 238 |
"最新版です": "You're up to date",
|
| 239 |
"最終確認 {0}": "Last checked {0}",
|
| 240 |
"有効": "On",
|
|
|
|
| 352 |
"{0} 以上推奨(1 では同じ言葉を繰り返すことがあります)": "建议 {0} 以上(为 1 时可能重复同一个词)",
|
| 353 |
"{0}秒": "{0} 秒",
|
| 354 |
"{0}秒 · {1}トークン · 文脈 {2}{3}": "{0}秒 · {1} token · 上下文 {2}{3}",
|
| 355 |
+
"↓ 最新へ": "↓ 最新",
|
| 356 |
"、": "、",
|
| 357 |
"「{0}」を削除しますか?(元に戻せません)": "删除“{0}”吗?(无法恢复)",
|
| 358 |
"「更新」を押すと自動で入れ替えて再起動します。": "点击“更新”即可自动替换并重启。",
|
|
|
|
| 529 |
"最大": "最大",
|
| 530 |
"最大トークン": "最大 token 数",
|
| 531 |
"最大(メモリが足りない PC ではエキスパートを SSD から読みます)": "最大(内存不足的电脑从 SSD 读取专家)",
|
| 532 |
+
"最新へ": "跳到最新",
|
| 533 |
"最新版です": "已是最新版本",
|
| 534 |
"最終確認 {0}": "上次检查 {0}",
|
| 535 |
"有効": "开启",
|
onw/web/index.html
CHANGED
|
@@ -28,7 +28,12 @@
|
|
| 28 |
button { font: inherit; cursor: pointer; border-radius: 8px; border: 1px solid var(--border); background: var(--panel); color: var(--text); padding: 6px 12px; }
|
| 29 |
button.primary { background: var(--accent); color: var(--accent-text); border-color: var(--accent); }
|
| 30 |
button:disabled { opacity: .5; cursor: default; }
|
| 31 |
-
#chat { flex: 1; overflow-y: auto; padding: 16px; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
.wrap { max-width: 820px; margin: 0 auto; }
|
| 33 |
.msg { margin: 0 0 14px; display: flex; }
|
| 34 |
.msg.user { justify-content: flex-end; }
|
|
@@ -109,7 +114,7 @@
|
|
| 109 |
<div class="bottom"><button id="delall">会話履歴をすべて削除</button></div>
|
| 110 |
</aside>
|
| 111 |
<div id="pane">
|
| 112 |
-
<main id="chat"><div class="wrap" id="log"><div class="empty" id="empty">メッセージを入力するか、画像を添付・貼り付け・ドロップしてください。</div></div></main>
|
| 113 |
<footer><div class="wrap">
|
| 114 |
<div id="thumbs"></div>
|
| 115 |
<div class="row">
|
|
@@ -141,6 +146,31 @@ let MODEL = ""; // this server's model id (saved with each conversa
|
|
| 141 |
// the saved conversation shown now: id (null until the first answer), when it began, its messages as stored
|
| 142 |
// ({role, text, images (small copies), reasoning, stats}) and the title
|
| 143 |
let chat = { id: null, created: 0, title: "", msgs: [] };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
let SAMPLING = null; // the model's recommended sampling (server info), its "think" set for thinking mode
|
| 145 |
const SFIELDS = { temperature: "temp", top_p: "topp", top_k: "topk", presence_penalty: "pres" };
|
| 146 |
function setSampling(s) { for (const [k, id] of Object.entries(SFIELDS)) if (s[k] !== undefined) $(id).value = s[k]; }
|
|
@@ -241,7 +271,7 @@ function addMsg(role, text, images) {
|
|
| 241 |
const b = document.createElement("div"); b.className = "bubble";
|
| 242 |
(images || []).forEach(src => { const im = new Image(); im.src = src; b.appendChild(im); });
|
| 243 |
const t = document.createElement("div"); t.innerHTML = role === "user" ? render(text) : text; b.appendChild(t);
|
| 244 |
-
m.appendChild(b); $("log").appendChild(m);
|
| 245 |
return { bubble: b, body: t };
|
| 246 |
}
|
| 247 |
function showThumbs() {
|
|
@@ -431,13 +461,13 @@ async function send() {
|
|
| 431 |
}
|
| 432 |
think += rc; lastThink = think; tbox.lastChild.textContent = think;
|
| 433 |
tbox.firstChild.textContent = acc ? T("思考") : T("思考(考え中… {0} 文字)", think.length);
|
| 434 |
-
if (tbox.open)
|
| 435 |
}
|
| 436 |
const d = j.choices?.[0]?.delta?.content;
|
| 437 |
if (d) {
|
| 438 |
if (!first) first = performance.now();
|
| 439 |
if (tbox && !acc) { tbox.open = false; tbox.firstChild.textContent = T("思考"); } // the answer starts: fold it away
|
| 440 |
-
acc += d; out.body.innerHTML = render(acc);
|
| 441 |
}
|
| 442 |
if (j.choices?.[0]?.finish_reason) finish = j.choices[0].finish_reason;
|
| 443 |
if (j.usage) usage = j.usage;
|
|
|
|
| 28 |
button { font: inherit; cursor: pointer; border-radius: 8px; border: 1px solid var(--border); background: var(--panel); color: var(--text); padding: 6px 12px; }
|
| 29 |
button.primary { background: var(--accent); color: var(--accent-text); border-color: var(--accent); }
|
| 30 |
button:disabled { opacity: .5; cursor: default; }
|
| 31 |
+
#chat { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; }
|
| 32 |
+
#chat > .wrap { width: 100%; }
|
| 33 |
+
#tolatest { order: 2; position: sticky; bottom: 4px; align-self: flex-end; margin-top: 8px; display: none; z-index: 2;
|
| 34 |
+
border-radius: 999px; padding: 6px 14px; box-shadow: 0 2px 10px rgba(0,0,0,.18); background: var(--accent);
|
| 35 |
+
color: var(--accent-text); border-color: var(--accent); }
|
| 36 |
+
#tolatest.show { display: block; }
|
| 37 |
.wrap { max-width: 820px; margin: 0 auto; }
|
| 38 |
.msg { margin: 0 0 14px; display: flex; }
|
| 39 |
.msg.user { justify-content: flex-end; }
|
|
|
|
| 114 |
<div class="bottom"><button id="delall">会話履歴をすべて削除</button></div>
|
| 115 |
</aside>
|
| 116 |
<div id="pane">
|
| 117 |
+
<main id="chat"><button id="tolatest" title="最新へ">↓ 最新へ</button><div class="wrap" id="log"><div class="empty" id="empty">メッセージを入力するか、画像を添付・貼り付け・ドロップしてください。</div></div></main>
|
| 118 |
<footer><div class="wrap">
|
| 119 |
<div id="thumbs"></div>
|
| 120 |
<div class="row">
|
|
|
|
| 146 |
// the saved conversation shown now: id (null until the first answer), when it began, its messages as stored
|
| 147 |
// ({role, text, images (small copies), reasoning, stats}) and the title
|
| 148 |
let chat = { id: null, created: 0, title: "", msgs: [] };
|
| 149 |
+
// auto-scroll: the view follows the answer while it is at the bottom; scrolling up stops that (so a thinking box can be
|
| 150 |
+
// opened, closed and read), and the "latest" button jumps back and follows again
|
| 151 |
+
// (the user's scrolling stops it at once - wheel, touch, keys - since the next token would scroll back down before
|
| 152 |
+
// the scroll event comes; a scroll above where we put the view, e.g. dragging the scroll bar, too)
|
| 153 |
+
let follow = true, autoTop = 0;
|
| 154 |
+
const atEnd = () => { const c = $("chat"); return c.scrollHeight - c.scrollTop - c.clientHeight < 48; };
|
| 155 |
+
const latestBtn = () => $("tolatest").classList.toggle("show", !atEnd());
|
| 156 |
+
function scrollEnd(force) {
|
| 157 |
+
if (force) follow = true;
|
| 158 |
+
else if (follow && !atEnd() && $("chat").scrollTop < autoTop - 4) follow = false; // moved up since (scroll bar)
|
| 159 |
+
if (follow) { $("chat").scrollTop = $("chat").scrollHeight; autoTop = $("chat").scrollTop; }
|
| 160 |
+
latestBtn();
|
| 161 |
+
}
|
| 162 |
+
function stopFollow() { follow = false; latestBtn(); }
|
| 163 |
+
$("chat").addEventListener("wheel", e => { if (e.deltaY < 0) stopFollow(); }, { passive: true });
|
| 164 |
+
$("chat").addEventListener("touchmove", stopFollow, { passive: true });
|
| 165 |
+
document.addEventListener("keydown", e => {
|
| 166 |
+
if (["PageUp", "ArrowUp", "Home"].includes(e.key) && !/^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName)) stopFollow();
|
| 167 |
+
});
|
| 168 |
+
$("chat").addEventListener("scroll", () => {
|
| 169 |
+
if (atEnd()) follow = true; // back at the bottom (scrolled down by hand)
|
| 170 |
+
else if ($("chat").scrollTop < autoTop - 4) follow = false; // above where the view was put
|
| 171 |
+
latestBtn();
|
| 172 |
+
});
|
| 173 |
+
$("tolatest").onclick = () => scrollEnd(true);
|
| 174 |
let SAMPLING = null; // the model's recommended sampling (server info), its "think" set for thinking mode
|
| 175 |
const SFIELDS = { temperature: "temp", top_p: "topp", top_k: "topk", presence_penalty: "pres" };
|
| 176 |
function setSampling(s) { for (const [k, id] of Object.entries(SFIELDS)) if (s[k] !== undefined) $(id).value = s[k]; }
|
|
|
|
| 271 |
const b = document.createElement("div"); b.className = "bubble";
|
| 272 |
(images || []).forEach(src => { const im = new Image(); im.src = src; b.appendChild(im); });
|
| 273 |
const t = document.createElement("div"); t.innerHTML = role === "user" ? render(text) : text; b.appendChild(t);
|
| 274 |
+
m.appendChild(b); $("log").appendChild(m); scrollEnd(true);
|
| 275 |
return { bubble: b, body: t };
|
| 276 |
}
|
| 277 |
function showThumbs() {
|
|
|
|
| 461 |
}
|
| 462 |
think += rc; lastThink = think; tbox.lastChild.textContent = think;
|
| 463 |
tbox.firstChild.textContent = acc ? T("思考") : T("思考(考え中… {0} 文字)", think.length);
|
| 464 |
+
if (tbox.open) scrollEnd();
|
| 465 |
}
|
| 466 |
const d = j.choices?.[0]?.delta?.content;
|
| 467 |
if (d) {
|
| 468 |
if (!first) first = performance.now();
|
| 469 |
if (tbox && !acc) { tbox.open = false; tbox.firstChild.textContent = T("思考"); } // the answer starts: fold it away
|
| 470 |
+
acc += d; out.body.innerHTML = render(acc); scrollEnd();
|
| 471 |
}
|
| 472 |
if (j.choices?.[0]?.finish_reason) finish = j.choices[0].finish_reason;
|
| 473 |
if (j.usage) usage = j.usage;
|
pyproject.toml
CHANGED
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
| 4 |
|
| 5 |
[project]
|
| 6 |
name = "onw"
|
| 7 |
-
version = "1.1.
|
| 8 |
description = "Ore no NPU ga konna ni ugoku wake nai - LLMs entirely on the Intel NPU"
|
| 9 |
readme = "README.md"
|
| 10 |
license = {text = "Apache-2.0"}
|
|
|
|
| 4 |
|
| 5 |
[project]
|
| 6 |
name = "onw"
|
| 7 |
+
version = "1.1.2"
|
| 8 |
description = "Ore no NPU ga konna ni ugoku wake nai - LLMs entirely on the Intel NPU"
|
| 9 |
readme = "README.md"
|
| 10 |
license = {text = "Apache-2.0"}
|