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
+
+ 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
+
Run On-Device
@@ -80,6 +88,11 @@ Saved-Page Library & Offline Search
Search
+
+
Saved Recipes
+
Show saved recipes
+
+
@@ -280,7 +293,10 @@ 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),
+ crawl:$("crawl").value
};
if(pdfBase64){body.pdf_base64=pdfBase64;body.filename=pdfFilename;}
try{
@@ -308,6 +324,23 @@ 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;
+ 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..26ae6ef 100644
--- a/tests/test_server.py
+++ b/tests/test_server.py
@@ -165,3 +165,41 @@ 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
+
+
+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