onwx 1.3.1: image encoder weights kept bf16 (1.8 -> 0.9 GB), OpenBLAS 8 threads; chat, window and benchmark fixes
Browse files- CHANGELOG.md +7 -0
- CHANGELOG_en.md +7 -0
- onwx/__init__.py +1 -1
- onwx/app.py +3 -0
- onwx/chat.py +5 -3
- onwx/qwen_vision.py +4 -3
- onwx/web/bench.html +14 -10
- onwx/web/index.html +10 -2
- onwx/web/manage.html +9 -5
CHANGELOG.md
CHANGED
|
@@ -5,6 +5,13 @@
|
|
| 5 |
日付は日本時間です。各版は Hugging Face の `ryugyosoft/onwx` にタグ(`v0.2.0` 以降)付きで残っています。
|
| 6 |
onwx ウィンドウは、新しい版があるとこのファイルから変更点を表示します(`## v…` の見出しで区切っています)。
|
| 7 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
## v1.3.0 (2026-10-31)
|
| 9 |
- 検証ページを **ベンチマーク**(`/bench`)に変えました。128・1K・2K・4K(8K も選べます)トークンのプロンプトで、プロンプトの読み込み(プリフィル)の速さと、そのあとの生成(デコード)の速さを表にします。プロンプトはそのモデル自身のトークナイザーで長さを合わせ、毎回違う一文で始めるので前の会話は再利用されません。「結果をコピー(Markdown)」で、アプリ・モデル・CPU・OS と一緒に表をコピーできます(記事などに)。トレイとウィンドウの「検証ページ」も「ベンチマーク」になり、前のアドレス(`/check`)はベンチマークへ移ります
|
| 10 |
- チャット画面の上部に、直前の回答のプリフィル速度と生成速度を出すようにしました
|
|
|
|
| 5 |
日付は日本時間です。各版は Hugging Face の `ryugyosoft/onwx` にタグ(`v0.2.0` 以降)付きで残っています。
|
| 6 |
onwx ウィンドウは、新しい版があるとこのファイルから変更点を表示します(`## v…` の見出しで区切っています)。
|
| 7 |
|
| 8 |
+
## v1.3.1 (2026-10-31)
|
| 9 |
+
- 画像を読むエンコーダー(Qwen3.5)の重みを bf16 のまま持つようにしました(最初の画像のあと、約 1.8 GB → 約 0.9 GB)。メモリ 24 GB 未満の PC では、画像を読み終えたら手放します(onw と同じ。`ONWX_KEEP_VISION=1` / `0` で指定もできます)
|
| 10 |
+
- サーバーの numpy が CPU のスレッド数ぶん確保していたメモリ(コミット済み、約 750 MB)を約 250 MB にしました(使う量は変わりません)
|
| 11 |
+
- チャット画面:回答中の会話を履歴から開き直すと、直前の質問と途中の回答が消えることがあったのを直しました。上部の速度表示は、新しい会話・別の会話・モデルの切り替えで消えるようにしました。狭いウィンドウでヘッダーが崩れていたのを直しました
|
| 12 |
+
- ウィンドウ:設定を保存した直後に、古い値が一瞬表示に戻ることがあったのを直しました
|
| 13 |
+
- ベンチマーク:コピーする条件(生成トークン数・回数)を、測ったときのものにしました。2 回目以降が失敗しても、それまでの結果を残します。最良値は、いちばん速かった回のトークン数・再利用数で出します。モデルの読み込み中に開いても、読み込みが終われば使えます。考えるモデルでは、コンテキストに収まるかの判定に思考の分も入れました
|
| 14 |
+
|
| 15 |
## v1.3.0 (2026-10-31)
|
| 16 |
- 検証ページを **ベンチマーク**(`/bench`)に変えました。128・1K・2K・4K(8K も選べます)トークンのプロンプトで、プロンプトの読み込み(プリフィル)の速さと、そのあとの生成(デコード)の速さを表にします。プロンプトはそのモデル自身のトークナイザーで長さを合わせ、毎回違う一文で始めるので前の会話は再利用されません。「結果をコピー(Markdown)」で、アプリ・モデル・CPU・OS と一緒に表をコピーできます(記事などに)。トレイとウィンドウの「検証ページ」も「ベンチマーク」になり、前のアドレス(`/check`)はベンチマークへ移ります
|
| 17 |
- チャット画面の上部に、直前の回答のプリフィル速度と生成速度を出すようにしました
|
CHANGELOG_en.md
CHANGED
|
@@ -5,6 +5,13 @@
|
|
| 5 |
Dates are Japan time. Every version is kept on Hugging Face `ryugyosoft/onwx` with a tag (from `v0.2.0` on).
|
| 6 |
The onwx window shows what changed from this file when there is a newer version (sections start at `## v…`).
|
| 7 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
## v1.3.0 (2026-10-31)
|
| 9 |
- The check page is now a **benchmark** (`/bench`): prompts of 128, 1K, 2K and 4K tokens (8K can be added), with how fast the prompt is read (prefill) and how fast the answer is then generated (decode). The prompts are cut to length by the model's own tokenizer and start with a different sentence every time, so nothing of an earlier conversation is reused. "Copy the results (Markdown)" copies the table with the app, model, CPU and OS (for articles and posts). The tray's and the window's "check page" entries open the benchmark, and the old address (`/check`) leads there
|
| 10 |
- The chat page shows the last answer's prefill and decode speed at the top
|
|
|
|
| 5 |
Dates are Japan time. Every version is kept on Hugging Face `ryugyosoft/onwx` with a tag (from `v0.2.0` on).
|
| 6 |
The onwx window shows what changed from this file when there is a newer version (sections start at `## v…`).
|
| 7 |
|
| 8 |
+
## v1.3.1 (2026-10-31)
|
| 9 |
+
- The image encoder (Qwen3.5) keeps its weights in bf16 (after the first image about 1.8 GB -> about 0.9 GB); on PCs with less than 24 GB it is let go after each image (as in onw; `ONWX_KEEP_VISION=1` / `0` decides instead)
|
| 10 |
+
- The server's numpy reserved memory per CPU thread (about 750 MB committed); now about 250 MB (what it uses is unchanged)
|
| 11 |
+
- Chat page: reopening the conversation that is being answered could lose its last question and the partial answer: fixed. The speeds at the top go away with a new conversation, another conversation or another model. The header no longer breaks up in narrow windows
|
| 12 |
+
- Window: right after a save the old values could flash back for a moment: fixed
|
| 13 |
+
- Benchmark: the copied conditions (tokens generated, runs) are the measured run's; a failed later run keeps the results before it; the best result shows the fastest run's own token and reuse counts; opened while the model loads, it works once the model is in; for thinking models the context check counts the thinking too
|
| 14 |
+
|
| 15 |
## v1.3.0 (2026-10-31)
|
| 16 |
- The check page is now a **benchmark** (`/bench`): prompts of 128, 1K, 2K and 4K tokens (8K can be added), with how fast the prompt is read (prefill) and how fast the answer is then generated (decode). The prompts are cut to length by the model's own tokenizer and start with a different sentence every time, so nothing of an earlier conversation is reused. "Copy the results (Markdown)" copies the table with the app, model, CPU and OS (for articles and posts). The tray's and the window's "check page" entries open the benchmark, and the old address (`/check`) leads there
|
| 17 |
- The chat page shows the last answer's prefill and decode speed at the top
|
onwx/__init__.py
CHANGED
|
@@ -2,6 +2,6 @@
|
|
| 2 |
|
| 3 |
(A working name: the product's name is set here and in the install scripts.)
|
| 4 |
"""
|
| 5 |
-
__version__ = "1.3.
|
| 6 |
NAME = "onwx"
|
| 7 |
TITLE = "onwx (AMD Ryzen AI NPU)"
|
|
|
|
| 2 |
|
| 3 |
(A working name: the product's name is set here and in the install scripts.)
|
| 4 |
"""
|
| 5 |
+
__version__ = "1.3.1"
|
| 6 |
NAME = "onwx"
|
| 7 |
TITLE = "onwx (AMD Ryzen AI NPU)"
|
onwx/app.py
CHANGED
|
@@ -666,6 +666,9 @@ def _start_server(cfg):
|
|
| 666 |
if int(cfg["context"]) != 1024:
|
| 667 |
args += ["--context", str(cfg["context"])]
|
| 668 |
env = {**os.environ, "PYTHONUTF8": "1"}
|
|
|
|
|
|
|
|
|
|
| 669 |
if cfg.get("api_key"):
|
| 670 |
env["ONWX_API_KEY"] = cfg["api_key"]
|
| 671 |
if cfg.get("think_default"): # API requests that say nothing about thinking: think
|
|
|
|
| 666 |
if int(cfg["context"]) != 1024:
|
| 667 |
args += ["--context", str(cfg["context"])]
|
| 668 |
env = {**os.environ, "PYTHONUTF8": "1"}
|
| 669 |
+
# [bugs 10-31] numpy's OpenBLAS reserves a buffer per CPU thread at import (~750 MB committed with 24-32 threads,
|
| 670 |
+
# ~30 MB used); 8 threads keep the CPU-side matrix work (image encoders) fast
|
| 671 |
+
env.setdefault("OPENBLAS_NUM_THREADS", "8")
|
| 672 |
if cfg.get("api_key"):
|
| 673 |
env["ONWX_API_KEY"] = cfg["api_key"]
|
| 674 |
if cfg.get("think_default"): # API requests that say nothing about thinking: think
|
onwx/chat.py
CHANGED
|
@@ -76,11 +76,13 @@ class ChatEngine:
|
|
| 76 |
self._drop = sorted(specials - set(_tools.MARKERS), key=len, reverse=True) # ... when tool markup is kept
|
| 77 |
self._tool_stop = {i for i in (self.tok.convert_tokens_to_ids(t) for t in _tools.STOP_AFTER)
|
| 78 |
if isinstance(i, int) and i != self.tok.unk_token_id}
|
| 79 |
-
# images (Qwen3.5 with its vision tower): the tower runs on the CPU, loaded at the first image (~
|
| 80 |
-
# kept while the model is (
|
|
|
|
| 81 |
self.vision = self.model.meta.get("vision")
|
| 82 |
self._venc, self._grids = None, []
|
| 83 |
-
|
|
|
|
| 84 |
# end-of-turn ids: text config, full config and generation_config (Gemma lists <eos>, <turn|>, ...)
|
| 85 |
self.eos = {self.tok.eos_token_id}
|
| 86 |
cands = [self.model.cfg.get("eos_token_id")]
|
|
|
|
| 76 |
self._drop = sorted(specials - set(_tools.MARKERS), key=len, reverse=True) # ... when tool markup is kept
|
| 77 |
self._tool_stop = {i for i in (self.tok.convert_tokens_to_ids(t) for t in _tools.STOP_AFTER)
|
| 78 |
if isinstance(i, int) and i != self.tok.unk_token_id}
|
| 79 |
+
# images (Qwen3.5 with its vision tower): the tower runs on the CPU, loaded at the first image (~0.9 GB, its
|
| 80 |
+
# weights bf16) and kept while the model is on PCs with 24 GB or more ([bugs 10-31] as onw; smaller ones let
|
| 81 |
+
# it go after each image); ONWX_KEEP_VISION=1 / 0 decides instead
|
| 82 |
self.vision = self.model.meta.get("vision")
|
| 83 |
self._venc, self._grids = None, []
|
| 84 |
+
kv, ram = os.environ.get("ONWX_KEEP_VISION", ""), total_ram_gb()
|
| 85 |
+
self.keep_vision = kv == "1" if kv in ("0", "1") else (ram is None or ram >= 24)
|
| 86 |
# end-of-turn ids: text config, full config and generation_config (Gemma lists <eos>, <turn|>, ...)
|
| 87 |
self.eos = {self.tok.eos_token_id}
|
| 88 |
cands = [self.model.cfg.get("eos_token_id")]
|
onwx/qwen_vision.py
CHANGED
|
@@ -70,15 +70,16 @@ class QwenVision:
|
|
| 70 |
"""d: the model folder (vision.npz); cfg: config.json's vision_config"""
|
| 71 |
self.cfg = cfg
|
| 72 |
z = np.load(os.path.join(d, "vision.npz"))
|
| 73 |
-
|
| 74 |
-
|
| 75 |
self.D, self.nh = cfg["hidden_size"], cfg["num_heads"]
|
| 76 |
self.hd = self.D // self.nh
|
| 77 |
self.side = int(cfg["num_position_embeddings"] ** 0.5)
|
| 78 |
self.m = cfg["spatial_merge_size"]
|
| 79 |
|
| 80 |
def _w(self, n):
|
| 81 |
-
|
|
|
|
| 82 |
|
| 83 |
def _tables(self, grid):
|
| 84 |
"""position embeddings [N, D] and rotary cos / sin [N, hd] of the patches (merge-window order)"""
|
|
|
|
| 70 |
"""d: the model folder (vision.npz); cfg: config.json's vision_config"""
|
| 71 |
self.cfg = cfg
|
| 72 |
z = np.load(os.path.join(d, "vision.npz"))
|
| 73 |
+
# [bugs 10-31] kept as stored (bf16 as uint16: half of float32's memory), widened when a weight is used
|
| 74 |
+
self.W = {k: (z[k] if z[k].dtype == np.uint16 else z[k].astype(np.float32)) for k in z.files}
|
| 75 |
self.D, self.nh = cfg["hidden_size"], cfg["num_heads"]
|
| 76 |
self.hd = self.D // self.nh
|
| 77 |
self.side = int(cfg["num_position_embeddings"] ** 0.5)
|
| 78 |
self.m = cfg["spatial_merge_size"]
|
| 79 |
|
| 80 |
def _w(self, n):
|
| 81 |
+
a = self.W[VP + n]
|
| 82 |
+
return (a.astype(np.uint32) << 16).view(np.float32) if a.dtype == np.uint16 else a
|
| 83 |
|
| 84 |
def _tables(self, grid):
|
| 85 |
"""position embeddings [N, D] and rotary cos / sin [N, hd] of the patches (merge-window order)"""
|
onwx/web/bench.html
CHANGED
|
@@ -68,7 +68,7 @@ td.big { font-weight: 700; }
|
|
| 68 |
<script>
|
| 69 |
translate(document.body);
|
| 70 |
const $ = id => document.getElementById(id);
|
| 71 |
-
let INFO = {}, KEY = new URLSearchParams(location.search).get("key") || "", RESULTS = [];
|
| 72 |
|
| 73 |
const esc = s => String(s ?? "").replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
| 74 |
const fmt = (x, d) => (x == null || !isFinite(x)) ? "-" : Number(x).toLocaleString(undefined, { minimumFractionDigits: d, maximumFractionDigits: d });
|
|
@@ -116,7 +116,7 @@ async function run() {
|
|
| 116 |
const lens = [...document.querySelectorAll(".len")].filter(c => c.checked).map(c => +c.value);
|
| 117 |
const gen = +$("gen").value, reps = +$("reps").value;
|
| 118 |
const ctx = INFO.context || INFO.max_context || 0;
|
| 119 |
-
$("run").disabled = true; $("copy").disabled = true; RESULTS = []; show();
|
| 120 |
try {
|
| 121 |
$("status").textContent = T("慣らし運転…");
|
| 122 |
await api(await prompt(64, 0), 8);
|
|
@@ -124,16 +124,16 @@ async function run() {
|
|
| 124 |
let k = 0;
|
| 125 |
for (const n of lens) {
|
| 126 |
const label = T("{0} トークン", n.toLocaleString());
|
| 127 |
-
if (ctx && n + gen + 64 > ctx) { RESULTS.push({ label, err: T("コンテキスト長({0})を超えるので飛ばしました", ctx) }); show(); continue; }
|
| 128 |
let best = null;
|
| 129 |
for (let i = 0; i < reps; i++) {
|
| 130 |
$("status").textContent = T("{0}({1}/{2}回目)…", label, i + 1, reps);
|
| 131 |
try {
|
| 132 |
const m = measure(await api(await prompt(n, ++k), gen));
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
} catch (e) { best = { err: e.message }; break; }
|
| 137 |
}
|
| 138 |
RESULTS.push({ label: best.err ? label : T("{0} トークン", best.prompt.toLocaleString()), ...best }); show();
|
| 139 |
}
|
|
@@ -142,7 +142,7 @@ async function run() {
|
|
| 142 |
|
| 143 |
function markdown() {
|
| 144 |
const env = [[T("アプリ"), INFO.app], [T("モデル"), INFO.model], [T("デバイス"), Object.values(INFO.devices || {}).join(", ")],
|
| 145 |
-
[T("CPU"), INFO.cpu], [T("OS"), INFO.os], [T("条件"), T("温度 0・思考オフ・生成 {0} トークン・{1} 回の最良値",
|
| 146 |
[T("測定日"), new Date().toLocaleDateString()]].filter(([, v]) => v);
|
| 147 |
const head = `| ${T("プロンプト")} | ${T("プリフィル")} | ${T("プリフィル速度")} | ${T("生成速度")} |\n|---|---:|---:|---:|`;
|
| 148 |
const body = RESULTS.filter(r => !r.err).map(r =>
|
|
@@ -157,17 +157,21 @@ $("copy").onclick = async () => {
|
|
| 157 |
document.execCommand("copy"); a.remove(); $("status").textContent = T("コピーしました"); }
|
| 158 |
};
|
| 159 |
|
|
|
|
| 160 |
fetch("/api/info").then(r => r.json()).then(i => {
|
|
|
|
| 161 |
INFO = i;
|
| 162 |
document.title = $("h1").textContent = T("{0} ベンチマーク", (i.app || "").split(" ")[0] || "LLM");
|
| 163 |
const rows = [[T("アプリ"), i.app], [T("モデル名"), i.model], [T("デバイス"), Object.values(i.devices || {}).join(", ")],
|
| 164 |
-
[T("CPU"), i.cpu], [T("OS"), i.os], [T("コンテキスト長"), T("{0} トークン", i.context || i.max_context)],
|
| 165 |
[T("先読み(MTP・ドラフト)"), i.mtp ? T("有効") : T("なし")]].filter(([, v]) => v);
|
| 166 |
$("info").innerHTML = rows.map(([k, v]) => `<tr><td>${esc(k)}</td><td>${esc(v)}</td></tr>`).join("");
|
| 167 |
$("base").textContent = location.origin + "/v1"; $("mid").textContent = i.model;
|
| 168 |
const ctx = i.context || i.max_context || 0;
|
| 169 |
for (const c of document.querySelectorAll(".len")) if (ctx && +c.value + 512 > ctx) { c.checked = false; c.disabled = true; }
|
| 170 |
-
}).catch(() => { $("info").innerHTML = `<tr><td class='bad'>${T("サーバーに接続できません")}</td></tr>`; });
|
|
|
|
|
|
|
| 171 |
$("run").onclick = run;
|
| 172 |
</script>
|
| 173 |
</body>
|
|
|
|
| 68 |
<script>
|
| 69 |
translate(document.body);
|
| 70 |
const $ = id => document.getElementById(id);
|
| 71 |
+
let INFO = {}, KEY = new URLSearchParams(location.search).get("key") || "", RESULTS = [], LAST = {};
|
| 72 |
|
| 73 |
const esc = s => String(s ?? "").replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
| 74 |
const fmt = (x, d) => (x == null || !isFinite(x)) ? "-" : Number(x).toLocaleString(undefined, { minimumFractionDigits: d, maximumFractionDigits: d });
|
|
|
|
| 116 |
const lens = [...document.querySelectorAll(".len")].filter(c => c.checked).map(c => +c.value);
|
| 117 |
const gen = +$("gen").value, reps = +$("reps").value;
|
| 118 |
const ctx = INFO.context || INFO.max_context || 0;
|
| 119 |
+
$("run").disabled = true; $("copy").disabled = true; RESULTS = []; LAST = { gen, reps }; show();
|
| 120 |
try {
|
| 121 |
$("status").textContent = T("慣らし運転…");
|
| 122 |
await api(await prompt(64, 0), 8);
|
|
|
|
| 124 |
let k = 0;
|
| 125 |
for (const n of lens) {
|
| 126 |
const label = T("{0} トークン", n.toLocaleString());
|
| 127 |
+
if (ctx && n + gen + (INFO.always_thinks ? 1024 : 0) + 64 > ctx) { RESULTS.push({ label, err: T("コンテキスト長({0})を超えるので飛ばしました", ctx) }); show(); continue; }
|
| 128 |
let best = null;
|
| 129 |
for (let i = 0; i < reps; i++) {
|
| 130 |
$("status").textContent = T("{0}({1}/{2}回目)…", label, i + 1, reps);
|
| 131 |
try {
|
| 132 |
const m = measure(await api(await prompt(n, ++k), gen));
|
| 133 |
+
const dec = Math.max(best?.decode_tps || 0, m.decode_tps || 0);
|
| 134 |
+
if (!best || (m.prefill_tps || 0) > (best.prefill_tps || 0)) best = { ...m };
|
| 135 |
+
best.decode_tps = dec || null;
|
| 136 |
+
} catch (e) { if (!best) best = { err: e.message }; break; } // (a run that came through stays)
|
| 137 |
}
|
| 138 |
RESULTS.push({ label: best.err ? label : T("{0} トークン", best.prompt.toLocaleString()), ...best }); show();
|
| 139 |
}
|
|
|
|
| 142 |
|
| 143 |
function markdown() {
|
| 144 |
const env = [[T("アプリ"), INFO.app], [T("モデル"), INFO.model], [T("デバイス"), Object.values(INFO.devices || {}).join(", ")],
|
| 145 |
+
[T("CPU"), INFO.cpu], [T("OS"), INFO.os], [T("条件"), T("温度 0・思考オフ・生成 {0} トークン・{1} 回の最良値", LAST.gen, LAST.reps)],
|
| 146 |
[T("測定日"), new Date().toLocaleDateString()]].filter(([, v]) => v);
|
| 147 |
const head = `| ${T("プロンプト")} | ${T("プリフィル")} | ${T("プリフィル速度")} | ${T("生成速度")} |\n|---|---:|---:|---:|`;
|
| 148 |
const body = RESULTS.filter(r => !r.err).map(r =>
|
|
|
|
| 157 |
document.execCommand("copy"); a.remove(); $("status").textContent = T("コピーしました"); }
|
| 158 |
};
|
| 159 |
|
| 160 |
+
function loadInfo() {
|
| 161 |
fetch("/api/info").then(r => r.json()).then(i => {
|
| 162 |
+
if (!i.model) { setTimeout(loadInfo, 2000); return; } // (the model is still loading)
|
| 163 |
INFO = i;
|
| 164 |
document.title = $("h1").textContent = T("{0} ベンチマーク", (i.app || "").split(" ")[0] || "LLM");
|
| 165 |
const rows = [[T("アプリ"), i.app], [T("モデル名"), i.model], [T("デバイス"), Object.values(i.devices || {}).join(", ")],
|
| 166 |
+
[T("CPU"), i.cpu], [T("OS"), i.os], [T("コンテキスト長"), (i.context || i.max_context) && T("{0} トークン", i.context || i.max_context)],
|
| 167 |
[T("先読み(MTP・ドラフト)"), i.mtp ? T("有効") : T("なし")]].filter(([, v]) => v);
|
| 168 |
$("info").innerHTML = rows.map(([k, v]) => `<tr><td>${esc(k)}</td><td>${esc(v)}</td></tr>`).join("");
|
| 169 |
$("base").textContent = location.origin + "/v1"; $("mid").textContent = i.model;
|
| 170 |
const ctx = i.context || i.max_context || 0;
|
| 171 |
for (const c of document.querySelectorAll(".len")) if (ctx && +c.value + 512 > ctx) { c.checked = false; c.disabled = true; }
|
| 172 |
+
}).catch(() => { $("info").innerHTML = `<tr><td class='bad'>${T("サーバーに接続できません")}</td></tr>`; setTimeout(loadInfo, 3000); });
|
| 173 |
+
}
|
| 174 |
+
loadInfo();
|
| 175 |
$("run").onclick = run;
|
| 176 |
</script>
|
| 177 |
</body>
|
onwx/web/index.html
CHANGED
|
@@ -21,8 +21,11 @@
|
|
| 21 |
html, body { height: 100%; margin: 0; }
|
| 22 |
body { background: var(--bg); color: var(--text); font: 15px/1.6 system-ui, -apple-system, "Segoe UI", "Hiragino Sans", "Noto Sans JP", sans-serif; display: flex; flex-direction: column; }
|
| 23 |
header { display: flex; align-items: center; gap: 12px; padding: 10px 16px; border-bottom: 1px solid var(--border); background: var(--panel); }
|
| 24 |
-
header h1 { font-size: 16px; margin: 0; font-weight: 600; }
|
| 25 |
-
.badge { font-size: 12px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--border); color: var(--muted); }
|
|
|
|
|
|
|
|
|
|
| 26 |
.badge.ok { color: var(--accent); border-color: var(--accent); }
|
| 27 |
header .spacer { flex: 1; }
|
| 28 |
button { font: inherit; cursor: pointer; border-radius: 8px; border: 1px solid var(--border); background: var(--panel); color: var(--text); padding: 6px 12px; }
|
|
@@ -226,6 +229,7 @@ function loadInfo() { // the server's model and settings: tried again until it
|
|
| 226 |
const id = i.model || i.title || "", first = !MODEL;
|
| 227 |
if (MODEL && id === MODEL) return; // the same model: its settings (and the user's changes) stay
|
| 228 |
MODEL = id;
|
|
|
|
| 229 |
if (i.title) { $("title").textContent = i.title; document.title = i.title + " on NPU"; }
|
| 230 |
VISION = i.vision !== false; $("attach").style.display = VISION ? "" : "none";
|
| 231 |
if (!VISION) { pending = []; showThumbs(); if (first && $("empty")) $("empty").textContent = T("メッセージを入力してください。"); }
|
|
@@ -398,8 +402,10 @@ document.addEventListener("dragleave", () => document.body.classList.remove("dra
|
|
| 398 |
document.addEventListener("drop", e => { e.preventDefault(); document.body.classList.remove("drag"); addFiles(e.dataTransfer.files); });
|
| 399 |
$("input").addEventListener("input", e => { e.target.style.height = "auto"; e.target.style.height = e.target.scrollHeight + "px"; });
|
| 400 |
$("input").addEventListener("keydown", e => { if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); if (!controller) send(); } }); // (not while an answer is written)
|
|
|
|
| 401 |
function newChat() {
|
| 402 |
if (controller) controller.abort();
|
|
|
|
| 403 |
history.length = 0; pending = []; showThumbs(); chat = { id: null, created: 0, title: "", msgs: [] };
|
| 404 |
$("log").innerHTML = `<div class="empty" id="empty">${T("新しい会話です。")}</div>`;
|
| 405 |
listChats();
|
|
@@ -486,7 +492,9 @@ async function listChats() {
|
|
| 486 |
}
|
| 487 |
}
|
| 488 |
async function openChat(id) {
|
|
|
|
| 489 |
if (controller) controller.abort();
|
|
|
|
| 490 |
const r = await fetch("/api/chats/" + id); if (!r.ok) return listChats();
|
| 491 |
const c = await r.json();
|
| 492 |
chat = { id: c.id, created: c.created, title: c.title, model: c.model, msgs: c.messages || [] };
|
|
|
|
| 21 |
html, body { height: 100%; margin: 0; }
|
| 22 |
body { background: var(--bg); color: var(--text); font: 15px/1.6 system-ui, -apple-system, "Segoe UI", "Hiragino Sans", "Noto Sans JP", sans-serif; display: flex; flex-direction: column; }
|
| 23 |
header { display: flex; align-items: center; gap: 12px; padding: 10px 16px; border-bottom: 1px solid var(--border); background: var(--panel); }
|
| 24 |
+
header h1 { font-size: 16px; margin: 0; font-weight: 600; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
| 25 |
+
.badge { font-size: 12px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--border); color: var(--muted); white-space: nowrap; }
|
| 26 |
+
@media (max-width: 600px) { #speed { display: none !important; } }
|
| 27 |
+
header button { white-space: nowrap; flex-shrink: 0; }
|
| 28 |
+
#dev { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
| 29 |
.badge.ok { color: var(--accent); border-color: var(--accent); }
|
| 30 |
header .spacer { flex: 1; }
|
| 31 |
button { font: inherit; cursor: pointer; border-radius: 8px; border: 1px solid var(--border); background: var(--panel); color: var(--text); padding: 6px 12px; }
|
|
|
|
| 229 |
const id = i.model || i.title || "", first = !MODEL;
|
| 230 |
if (MODEL && id === MODEL) return; // the same model: its settings (and the user's changes) stay
|
| 231 |
MODEL = id;
|
| 232 |
+
clearSpeed();
|
| 233 |
if (i.title) { $("title").textContent = i.title; document.title = i.title + " on NPU"; }
|
| 234 |
VISION = i.vision !== false; $("attach").style.display = VISION ? "" : "none";
|
| 235 |
if (!VISION) { pending = []; showThumbs(); if (first && $("empty")) $("empty").textContent = T("メッセージを入力してください。"); }
|
|
|
|
| 402 |
document.addEventListener("drop", e => { e.preventDefault(); document.body.classList.remove("drag"); addFiles(e.dataTransfer.files); });
|
| 403 |
$("input").addEventListener("input", e => { e.target.style.height = "auto"; e.target.style.height = e.target.scrollHeight + "px"; });
|
| 404 |
$("input").addEventListener("keydown", e => { if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); if (!controller) send(); } }); // (not while an answer is written)
|
| 405 |
+
function clearSpeed() { const b = document.getElementById("speed"); if (b) b.style.display = "none"; }
|
| 406 |
function newChat() {
|
| 407 |
if (controller) controller.abort();
|
| 408 |
+
clearSpeed();
|
| 409 |
history.length = 0; pending = []; showThumbs(); chat = { id: null, created: 0, title: "", msgs: [] };
|
| 410 |
$("log").innerHTML = `<div class="empty" id="empty">${T("新しい会話です。")}</div>`;
|
| 411 |
listChats();
|
|
|
|
| 492 |
}
|
| 493 |
}
|
| 494 |
async function openChat(id) {
|
| 495 |
+
if (id === chat.id) return; // (the open one - an answer may still be coming in)
|
| 496 |
if (controller) controller.abort();
|
| 497 |
+
clearSpeed();
|
| 498 |
const r = await fetch("/api/chats/" + id); if (!r.ok) return listChats();
|
| 499 |
const c = await r.json();
|
| 500 |
chat = { id: c.id, created: c.created, title: c.title, model: c.model, msgs: c.messages || [] };
|
onwx/web/manage.html
CHANGED
|
@@ -189,14 +189,14 @@
|
|
| 189 |
<div class="card">
|
| 190 |
<div class="row">
|
| 191 |
<span class="muted">使うモデル</span>
|
| 192 |
-
<select class="grow" id="pick"></select>
|
| 193 |
<button class="primary" id="b-load" onclick="this.dataset.act === 'unload' ? post('unload') : loadPicked()">ロード</button>
|
| 194 |
</div>
|
| 195 |
<div class="muted small" style="margin-top:5px">一度に 1 モデル。別のモデルを選んで「切り替え」を押すと、今のモデルをアンロードしてから読み込みます。</div>
|
| 196 |
</div>
|
| 197 |
<div class="card set" id="prefcard">
|
| 198 |
<div class="row"><b>このモデルの既定</b><span class="muted small" id="pf-model"></span></div>
|
| 199 |
-
<div class="opt" id="pf-think-row"><span class="k">思考</span><input type="range" id="pf-think" min="0" max="3" step="1" style="width:220px"><b id="pf-think-label" class="small"></b>
|
| 200 |
<span class="desc" id="pf-think-desc"></span></div>
|
| 201 |
<div class="opt"><span class="k">生成設定</span><select id="pf-sampling" style="width:360px;max-width:100%"><option value="greedy">貪欲(温度 0。毎回同じ答え)</option><option value="recommended">モデルの推奨値</option><option value="custom">自分で指定</option></select></div>
|
| 202 |
<div class="opt" id="pf-custom"><span class="k"></span>
|
|
@@ -481,7 +481,7 @@
|
|
| 481 |
if ($("pf-think-row").style.display === "none") delete prefs.think;
|
| 482 |
const r = await post("model_prefs", { key, prefs });
|
| 483 |
if (r.error) return;
|
| 484 |
-
prefDirty = false; $("pf-saved").textContent = T("保存しました(すぐ反映されます)");
|
| 485 |
}
|
| 486 |
// row buttons carry the model key in data-key (any folder name, quotes too) - one handler for the table and the menu
|
| 487 |
function btn(act, key, label, cls, off) {
|
|
@@ -558,7 +558,7 @@
|
|
| 558 |
const oldLang = S.config.lang || "auto";
|
| 559 |
const r = await post("settings", body);
|
| 560 |
if (r.error) return;
|
| 561 |
-
setDirty(false);
|
| 562 |
$("saved").textContent = T("保存しました");
|
| 563 |
if (r.restart_needed && await ask(T("設定は保存しました。サーバーを新しい設定で今すぐ再起動しますか?(キャンセルしても、次にモデルを読み込むときから反映されます)"))) await post("restart");
|
| 564 |
if (oldLang !== body.lang) location.reload(); // the page is served in the new language
|
|
@@ -635,12 +635,16 @@
|
|
| 635 |
if (justUpdated) setTimeout(() => toast(T("v{0} に更新しました", justUpdated)), 400);
|
| 636 |
|
| 637 |
async function refresh() {
|
|
|
|
| 638 |
try {
|
| 639 |
const r = await fetch("/api/state?token=" + encodeURIComponent(TOKEN), {cache: "no-store"});
|
| 640 |
if (r.status === 403) { $("env").textContent = T("この画面は onwx のウィンドウかトレイから開いてください"); return; }
|
| 641 |
-
|
|
|
|
|
|
|
| 642 |
} catch (e) { $("hpill").className = "pill danger"; $("hpill").textContent = T("接続なし"); }
|
| 643 |
}
|
|
|
|
| 644 |
refresh(); setInterval(refresh, 1000);
|
| 645 |
</script>
|
| 646 |
</body>
|
|
|
|
| 189 |
<div class="card">
|
| 190 |
<div class="row">
|
| 191 |
<span class="muted">使うモデル</span>
|
| 192 |
+
<select class="grow" id="pick" aria-label="モデル"></select>
|
| 193 |
<button class="primary" id="b-load" onclick="this.dataset.act === 'unload' ? post('unload') : loadPicked()">ロード</button>
|
| 194 |
</div>
|
| 195 |
<div class="muted small" style="margin-top:5px">一度に 1 モデル。別のモデルを選んで「切り替え」を押すと、今のモデルをアンロードしてから読み込みます。</div>
|
| 196 |
</div>
|
| 197 |
<div class="card set" id="prefcard">
|
| 198 |
<div class="row"><b>このモデルの既定</b><span class="muted small" id="pf-model"></span></div>
|
| 199 |
+
<div class="opt" id="pf-think-row"><span class="k">思考</span><input type="range" id="pf-think" aria-label="思考" min="0" max="3" step="1" style="width:220px"><b id="pf-think-label" class="small"></b>
|
| 200 |
<span class="desc" id="pf-think-desc"></span></div>
|
| 201 |
<div class="opt"><span class="k">生成設定</span><select id="pf-sampling" style="width:360px;max-width:100%"><option value="greedy">貪欲(温度 0。毎回同じ答え)</option><option value="recommended">モデルの推奨値</option><option value="custom">自分で指定</option></select></div>
|
| 202 |
<div class="opt" id="pf-custom"><span class="k"></span>
|
|
|
|
| 481 |
if ($("pf-think-row").style.display === "none") delete prefs.think;
|
| 482 |
const r = await post("model_prefs", { key, prefs });
|
| 483 |
if (r.error) return;
|
| 484 |
+
STALE = REQ; prefDirty = false; $("pf-saved").textContent = T("保存しました(すぐ反映されます)");
|
| 485 |
}
|
| 486 |
// row buttons carry the model key in data-key (any folder name, quotes too) - one handler for the table and the menu
|
| 487 |
function btn(act, key, label, cls, off) {
|
|
|
|
| 558 |
const oldLang = S.config.lang || "auto";
|
| 559 |
const r = await post("settings", body);
|
| 560 |
if (r.error) return;
|
| 561 |
+
STALE = REQ; setDirty(false);
|
| 562 |
$("saved").textContent = T("保存しました");
|
| 563 |
if (r.restart_needed && await ask(T("設定は保存しました。サーバーを新しい設定で今すぐ再起動しますか?(キャンセルしても、次にモデルを読み込むときから反映されます)"))) await post("restart");
|
| 564 |
if (oldLang !== body.lang) location.reload(); // the page is served in the new language
|
|
|
|
| 635 |
if (justUpdated) setTimeout(() => toast(T("v{0} に更新しました", justUpdated)), 400);
|
| 636 |
|
| 637 |
async function refresh() {
|
| 638 |
+
const n = ++REQ;
|
| 639 |
try {
|
| 640 |
const r = await fetch("/api/state?token=" + encodeURIComponent(TOKEN), {cache: "no-store"});
|
| 641 |
if (r.status === 403) { $("env").textContent = T("この画面は onwx のウィンドウかトレイから開いてください"); return; }
|
| 642 |
+
const j = await r.json();
|
| 643 |
+
if (n <= STALE) return; // (asked before the last save: shows the old values)
|
| 644 |
+
S = j; render();
|
| 645 |
} catch (e) { $("hpill").className = "pill danger"; $("hpill").textContent = T("接続なし"); }
|
| 646 |
}
|
| 647 |
+
let REQ = 0, STALE = 0; // state polls / the last one started before a save
|
| 648 |
refresh(); setInterval(refresh, 1000);
|
| 649 |
</script>
|
| 650 |
</body>
|