Free & open source (MIT)
Summary (3-5 bullets + why it matters)
Key facts, numbers, names & dates
Claims check (evidence vs assertions)
+ Recipe (extract knowledge, save as .md)
Explain simply (ELI5)
Translate
Ask a question
+
+ Follow links: max
+ depth 1 2
+
Run On-Device
@@ -80,6 +85,11 @@ Saved-Page Library & Offline Search
Search
+
+
Saved Recipes
+
Show saved recipes
+
+
@@ -280,7 +290,9 @@ High-Compliance Consulting & Community Support
q:$("q").value,
lang,
gpu_layers:Number($("gpu-layers").value),
- cpu_threads:Number($("cpu-threads").value)
+ cpu_threads:Number($("cpu-threads").value),
+ max_links:Number($("max-links").value),
+ depth:Number($("depth").value)
};
if(pdfBase64){body.pdf_base64=pdfBase64;body.filename=pdfFilename;}
try{
@@ -308,6 +320,21 @@ High-Compliance Consulting & Community Support
}catch(e){$("out").textContent="Error: "+e.message;}
};
$("lib-search-btn").onclick=loadLibrary;
+$("mode").addEventListener("change",()=>{$("recipe-opts").style.display=$("mode").value==="recipe"?"block":"none";});
+async function loadRecipes(){
+ const box=$("recipe-list");if(!box)return;
+ try{
+ const r=await fetch("/recipes",{headers:headers()});const d=await r.json();
+ if(!r.ok){box.innerHTML=`${esc(d.error||"Error")}
`;return;}
+ box.innerHTML=`Saved in ${esc(d.dir)}
`+(d.items.length?d.items.map(it=>`${esc(it.name)} ยท ${esc(it.modified)} View Download
`).join(""):'No recipes yet. Pick "Recipe" and analyze a link.
');
+ box.querySelectorAll("button[data-r]").forEach(b=>b.onclick=async()=>{
+ const n=b.dataset.r,rr=await fetch("/recipes/"+encodeURIComponent(n),{headers:headers()});const t=await rr.text();
+ if(b.classList.contains("rv")){$("out").textContent=t;showTab("analyze");return;}
+ const a=document.createElement("a");a.href=URL.createObjectURL(new Blob([t],{type:"text/markdown"}));a.download=n;a.click();
+ });
+ }catch(e){box.innerHTML=`Error: ${esc(e.message)}
`;}
+}
+$("recipes-btn").onclick=loadRecipes;
$("preset-save-btn").onclick=async()=>{
const r=await fetch("/presets",{method:"POST",headers:headers(),body:JSON.stringify({
name:$("preset-name").value,category:$("preset-cat").value,prompt:$("preset-prompt").value
diff --git a/tests/test_server.py b/tests/test_server.py
index 51e36d8..50be9bb 100644
--- a/tests/test_server.py
+++ b/tests/test_server.py
@@ -165,3 +165,27 @@ def test_library_presets_and_pdf_work_without_license(app_client):
lib_miss = app_client.get("/library?q=nonexistentwordxyz", headers=headers).get_json()
assert lib_miss["count"] == 0
+
+
+def test_rank_links_skips_nav_social_media_and_dupes():
+ html = ('Vulkan nav Login '
+ 'Vulkan build guide again '
+ 'Vulkan image Share ')
+ links = server.rank_links("https://ex.com/a", html, "vulkan build", {"https://ex.com/a"}, 5)
+ assert links == ["https://ex.com/vulkan-build"]
+
+
+def test_recipe_mode_follows_links_and_saves(app_client, tmp_path, monkeypatch):
+ monkeypatch.setattr(server, "RECIPES_DIR", tmp_path / "recipes")
+ main = ("Vulkan build guide " * 20, 'Vulkan build steps ', "Vulkan build")
+ child = ("cmake -B build -DGGML_VULKAN=ON " * 20, "", "Child")
+ monkeypatch.setattr(server, "fetch_text_with_meta", lambda u, timeout=20: child if u.endswith("/child") else main)
+ monkeypatch.setattr(server, "llm_complete", lambda s, u, m, timeout=300: "- fact" if u.startswith("PAGE") else "# Vulkan Build\n## Problem\nx")
+ h = {"X-Token": "test-secret-token-12345"}
+ for _ in range(2):
+ out = app_client.post("/analyze", headers=h, json={"url": "https://ex.com/", "mode": "recipe"}).get_data(as_text=True)
+ assert "Reading 1/1: https://ex.com/child" in out and "Recipe saved" in out
+ names = [i["name"] for i in app_client.get("/recipes", headers=h).get_json()["items"]]
+ assert sorted(names) == ["vulkan-build-2.md", "vulkan-build.md"]
+ md = app_client.get("/recipes/vulkan-build.md", headers=h).get_data(as_text=True)
+ assert "- https://ex.com/child" in md and "## Date" in md
From bede16cfe83746e4509a26ddf945e943c4efa5f3 Mon Sep 17 00:00:00 2001
From: kukirin-ui <325067280+kukirin-ui@users.noreply.github.com>
Date: Wed, 14 Oct 2026 04:43:38 +0200
Subject: [PATCH 2/2] Recipe: Balanced/Max crawl modes, depth tracking, Stop
button, fact condensing
---
server/server.py | 63 ++++++++++++++++++++++++++++++++++++--------
server/ui.html | 8 +++++-
tests/test_server.py | 14 ++++++++++
3 files changed, 73 insertions(+), 12 deletions(-)
diff --git a/server/server.py b/server/server.py
index 5765082..13329de 100644
--- a/server/server.py
+++ b/server/server.py
@@ -466,13 +466,20 @@ def llm_complete(system, user, max_tokens, timeout=300):
return strip_think(r.json()["choices"][0]["message"].get("content") or "")
-def recipe_gen(url, title, text, raw, max_links=5, depth=1, ctx=2048):
+RECIPE_STOP = {"flag": False}
+
+
+def recipe_gen(url, title, text, raw, max_links=5, depth=1, ctx=2048, crawl="balanced"):
"""Sequential (thermal-friendly): main page + ranked child links -> facts -> merged recipe(s)."""
import time
start = time.time()
- time_budget = int(os.environ.get("RECIPE_TIME_BUDGET", "600"))
- char_budget = int(os.environ.get("RECIPE_CHAR_BUDGET", "40000"))
+ RECIPE_STOP["flag"] = False
+ is_max = crawl == "max"
+ if is_max:
+ depth, max_links = 2, max(max_links, 50)
+ time_budget = int(os.environ.get("RECIPE_TIME_BUDGET", "7200" if is_max else "600"))
+ char_budget = int(os.environ.get("RECIPE_CHAR_BUDGET", "1000000" if is_max else "40000"))
page_chars = max(800, (ctx - 300 - 200) * 3) # one page per call fits the context
sys_facts = "You extract practical knowledge. Use only the PAGE text. Be brief."
yield ""
@@ -480,14 +487,18 @@ def recipe_gen(url, title, text, raw, max_links=5, depth=1, ctx=2048):
visited = {url} if url else set()
if url and max_links > 0:
queue = rank_links(url, raw, f"{title} {text[:300]}", visited, max_links)
- if depth >= 2:
+ if depth >= 2 and not is_max:
queue = queue[: max(1, max_links // 2)]
visited.update(queue)
+ queue = [(q, 1) for q in queue]
total = len(queue)
yield f"> Found {total} relevant link(s) on the page.\n"
i = 0
while queue and len(pages) - 1 < max_links:
- u = queue.pop(0)
+ if RECIPE_STOP["flag"]:
+ yield "> Stopped. Making a recipe from what was read so far.\n"
+ break
+ u, lvl = queue.pop(0)
i += 1
if time.time() - start > time_budget * 0.5 or sum(len(p[2]) for p in pages) > char_budget:
yield "> Time/size budget reached, skipping the remaining links.\n"
@@ -502,15 +513,15 @@ def recipe_gen(url, title, text, raw, max_links=5, depth=1, ctx=2048):
yield "> skipped (no useful text)\n"
continue
pages.append((u, tt, t, rw))
- if depth >= 2 and len(pages) - 1 + len(queue) < max_links:
+ if lvl < depth and len(pages) - 1 + len(queue) < max_links:
more = rank_links(u, rw, f"{title} {tt}", visited, max_links - len(pages) + 1 - len(queue))
visited.update(more)
- queue.extend(more)
+ queue.extend((m, lvl + 1) for m in more)
total += len(more)
facts, used = [], []
for n, (u, tt, t, _) in enumerate(pages, 1):
- if time.time() - start > time_budget:
- yield "> Time budget reached, using what was read so far.\n"
+ if time.time() - start > time_budget or (RECIPE_STOP["flag"] and facts):
+ yield "> Stopped/time budget reached, using what was read so far.\n"
break
yield f"> Extracting key facts {n}/{len(pages)}...\n"
try:
@@ -532,7 +543,28 @@ def recipe_gen(url, title, text, raw, max_links=5, depth=1, ctx=2048):
yield "[The model returned no useful facts. Try another link or raise MAX_TOKENS.]"
return
yield f"> Writing recipe from {len(used)} source(s)...\n\n"
- merged = "\n\n".join(facts)[: max(800, (ctx - MAX_TOKENS - 400) * 3)]
+ limit = max(800, (ctx - MAX_TOKENS - 400) * 3)
+ rnd = 0
+ while len("\n\n".join(facts)) > limit and len(facts) > 1 and rnd < 4:
+ rnd += 1
+ groups, cur = [], ""
+ for f in facts:
+ if cur and len(cur) + len(f) > limit:
+ groups.append(cur)
+ cur = ""
+ cur += f[:limit] + "\n\n"
+ groups.append(cur)
+ if len(groups) >= len(facts):
+ break
+ yield f"> Condensing facts (round {rnd}, {len(groups)} group(s))...\n"
+ new = []
+ for g in groups:
+ try:
+ new.append(llm_complete(sys_facts, f"FACTS:\n{g}\n\nTASK: Merge into at most 10 short bullets. Keep exact commands.", 300))
+ except Exception as e: # noqa: BLE001
+ yield f"[{e}]\n"
+ facts = [x for x in new if x] or facts
+ merged = "\n\n".join(facts)[:limit]
try:
out = llm_complete(
"You are a private on-device assistant. Use only the FACTS.",
@@ -990,6 +1022,14 @@ def get_recipe(name):
headers={"Content-Disposition": f'attachment; filename="{name}"'})
+@app.post("/recipes/stop")
+def stop_recipe():
+ if not authorized():
+ return jsonify(error="Wrong or missing token."), 401
+ RECIPE_STOP["flag"] = True
+ return jsonify(ok=True)
+
+
@app.post("/analyze")
def analyze():
if not authorized():
@@ -1063,7 +1103,8 @@ def analyze():
except (TypeError, ValueError):
ml, dp = 5, 1
return Response(
- recipe_gen(d.get("url") or "", title, text, raw, ml, dp, ctx),
+ recipe_gen(d.get("url") or "", title, text, raw, ml, dp, ctx,
+ "max" if d.get("crawl") == "max" else "balanced"),
mimetype="text/plain; charset=utf-8",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
diff --git a/server/ui.html b/server/ui.html
index d264172..754168b 100644
--- a/server/ui.html
+++ b/server/ui.html
@@ -62,6 +62,9 @@ Nullcloud Free & open source (MIT)
Ask a question
+ Crawl Balanced Max
+ Stop
+ Max reads up to 50 pages (depth 2). It takes long and heats the phone. Stop keeps what was read.
Follow links: max
depth 1 2
@@ -292,7 +295,8 @@ High-Compliance Consulting & Community Support
gpu_layers:Number($("gpu-layers").value),
cpu_threads:Number($("cpu-threads").value),
max_links:Number($("max-links").value),
- depth:Number($("depth").value)
+ depth:Number($("depth").value),
+ crawl:$("crawl").value
};
if(pdfBase64){body.pdf_base64=pdfBase64;body.filename=pdfFilename;}
try{
@@ -320,6 +324,8 @@ High-Compliance Consulting & Community Support
}catch(e){$("out").textContent="Error: "+e.message;}
};
$("lib-search-btn").onclick=loadLibrary;
+$("crawl").onchange=()=>{$("max-warn").style.display=$("crawl").value==="max"?"inline":"none";};
+$("stop-btn").onclick=()=>fetch("/recipes/stop",{method:"POST",headers:headers()});
$("mode").addEventListener("change",()=>{$("recipe-opts").style.display=$("mode").value==="recipe"?"block":"none";});
async function loadRecipes(){
const box=$("recipe-list");if(!box)return;
diff --git a/tests/test_server.py b/tests/test_server.py
index 50be9bb..26ae6ef 100644
--- a/tests/test_server.py
+++ b/tests/test_server.py
@@ -189,3 +189,17 @@ def test_recipe_mode_follows_links_and_saves(app_client, tmp_path, monkeypatch):
assert sorted(names) == ["vulkan-build-2.md", "vulkan-build.md"]
md = app_client.get("/recipes/vulkan-build.md", headers=h).get_data(as_text=True)
assert "- https://ex.com/child" in md and "## Date" in md
+
+
+def test_recipe_max_mode_crawls_depth2_and_stop(app_client, tmp_path, monkeypatch):
+ monkeypatch.setattr(server, "RECIPES_DIR", tmp_path / "recipes")
+ def fetch(u, timeout=20):
+ n = int(u.rsplit("/", 1)[-1] or 0) if u.rsplit("/", 1)[-1].isdigit() else 0
+ links = "".join(f'vulkan build {k} ' for k in range(1, 4))
+ return ("Vulkan build text " * 30, links, f"Vulkan build {n}")
+ monkeypatch.setattr(server, "fetch_text_with_meta", fetch)
+ monkeypatch.setattr(server, "llm_complete", lambda s, u, m, timeout=300: "- fact" if not u.startswith("FACTS") or "Merge" in u else "# Vulkan Build\nx")
+ h = {"X-Token": "test-secret-token-12345"}
+ out = app_client.post("/analyze", headers=h, json={"url": "https://ex.com/", "mode": "recipe", "crawl": "max"}).get_data(as_text=True)
+ assert "Reading 12/12" in out and "Recipe saved" in out # 3 children + 9 grandchildren
+ assert app_client.post("/recipes/stop", headers=h).status_code == 200