ryugyosoft commited on
Commit
9ccf79f
·
verified ·
1 Parent(s): ac21b16

onw 1.1.2: chat auto-scroll stops when scrolled up, jump-to-latest button

Browse files
Files changed (8) hide show
  1. CHANGELOG.md +5 -1
  2. CHANGELOG_en.md +5 -1
  3. MANUAL.md +1 -0
  4. MANUAL_en.md +2 -1
  5. onw/__init__.py +1 -1
  6. onw/i18n.json +4 -0
  7. onw/web/index.html +35 -5
  8. 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.1 (2026-10-06)
 
 
 
 
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.1 (2026-10-06)
 
 
 
 
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.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); $("chat").scrollTop = $("chat").scrollHeight;
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) $("chat").scrollTop = $("chat").scrollHeight;
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); $("chat").scrollTop = $("chat").scrollHeight;
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.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"}