Ollama URL Summarizer

Summarize the current page’s URL using a configurable Ollama API endpoint.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey, Greasemonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да инсталирате разширение, като например Tampermonkey .

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Userscripts.

За да инсталирате скрипта, трябва да инсталирате разширение като Tampermonkey.

За да инсталирате този скрипт, трябва да имате инсталиран скриптов мениджър.

(Вече имам скриптов мениджър, искам да го инсталирам!)

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

(Вече имам инсталиран мениджър на стиловете, искам да го инсталирам!)

// ==UserScript==
// @name         Ollama URL Summarizer
// @namespace    https://greasyfork.org/en/users/807108-jeremy-r
// @version      1.4.0
// @description  Summarize the current page’s URL using a configurable Ollama API endpoint.
// @author       JRem
// @match        *://*/*
// @grant        GM_registerMenuCommand
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @connect      *
// @run-at       document-idle
// @license      MIT
// ==/UserScript==
/* --------------------------------------------------------------
   CONFIGURATION STORAGE & DEFAULTS
   -------------------------------------------------------------- */
const DEFAULTS = {
  // --------------------------------------------------------------
  // 1️⃣ Ollama endpoint – where the POST request will be sent.
  //    Example (local):    http://localhost:11434/api/chat
  //    Example (remote):   https://my-ollama-proxy.com/v1/chat/completions
  // --------------------------------------------------------------
  endpoint: "http://localhost:11434/api/chat",
  // --------------------------------------------------------------
  // 2️⃣ API key (if the endpoint needs authentication)
  // Using Ollama-Gateway to implement an API token
  // --------------------------------------------------------------
  apiKey: "",
  // --------------------------------------------------------------
  // 3️⃣ Model name
  // --------------------------------------------------------------
  model: "granite3.3:2b",
  // --------------------------------------------------------------
  // 4️⃣ Prompt – now expects a {{url}} placeholder
  // --------------------------------------------------------------
  systemPrompt:
    "You are a concise assistant. Summarize the following webpage content in no more than 3 short paragraphs, preserving the main ideas and any important facts. {{content}",
  // --------------------------------------------------------------
  // 5️⃣ Generation parameters
  // --------------------------------------------------------------
  temperature: 0.6,
  maxTokens: 2046,
  // ------------------------------------------------------------------
  // 6️⃣ How much of the page text to send (characters). Ollama’s default
  //    context window is 4 KB–128 KB depending on model, so we cap it.
  // ------------------------------------------------------------------
  maxPageChars: 15000
};

/* ----------------------------------------------------------------------
   Utility helpers
   ---------------------------------------------------------------------- */

function getSetting(key) {
  return GM_getValue(key, DEFAULTS[key]);
}
function setSetting(key, value) {
  GM_setValue(key, value);
}
function escapeHtml(str) {
  const div = document.createElement("div");
  div.textContent = str;
  return div.innerHTML;
}

/* ----------------------------------------------------------------------
   UI: modal for displaying the summary
   ---------------------------------------------------------------------- */

GM_addStyle(`
  .ollama-sum-modal {
    position: fixed; inset: 0; background: rgba(0,0,0,.6);
    display: flex; align-items: center; justify-content: center;
    z-index: 999999;
  }
  .ollama-sum-box {
    background:#fff; color:#111; max-width:800px; max-height:80vh;
    overflow:auto; padding:1.5rem; border-radius:8px; box-shadow:0 4px 15px rgba(0,0,0,.3);
    font-family:system-ui,Arial,sans-serif; line-height:1.5;
  }
  .ollama-sum-close {
    position:absolute; top:.5rem; right:.7rem; cursor:pointer;
    font-size:1.4rem; background:none; border:none; color:#555;
  }
  .ollama-sum-close:hover {color:#000;}
`);

function showModal(htmlContent) {
  const overlay = document.createElement("div");
  overlay.className = "ollama-sum-modal";

  const box = document.createElement("div");
  box.className = "ollama-sum-box";
  box.innerHTML = `
    <button class="ollama-sum-close" title="Close">&times;</button>
    ${htmlContent}
  `;

  overlay.appendChild(box);
  document.body.appendChild(overlay);

  overlay.querySelector(".ollama-sum-close").onclick = () => overlay.remove();
}

/* ----------------------------------------------------------------------
   UI: Settings dialog
   ---------------------------------------------------------------------- */

function openSettingsDialog() {
  const form = document.createElement("form");
  form.innerHTML = `
    <style>
      .ollama-set-table {width:100%; border-collapse:collapse;}
      .ollama-set-table td {padding:.4rem;}
      .ollama-set-table input, .ollama-set-table textarea {width:100%; box-sizing:border-box;}
      .ollama-set-table textarea {height:80px; resize:vertical;}
      .ollama-set-submit {margin-top:.8rem; padding:.5rem 1rem;}
    </style>
    <h2>Ollama Summarizer – Settings</h2>
    <table class="ollama-set-table">
      <tr><td>Endpoint URL</td>
          <td><input type="url" name="endpoint" required></td></tr>
      <tr><td>API Key (optional)</td>
          <td><input type="text" name="apiKey"></td></tr>
      <tr><td>Model</td>
          <td><input type="text" name="model" required></td></tr>
      <tr><td>System Prompt<br><small>Use <code>{{content}}</code> as placeholder.</small></td>
          <td><textarea name="systemPrompt" required></textarea></td></tr>
      <tr><td>Temperature (0‑1)</td>
          <td><input type="number" name="temperature" step="0.01" min="0" max="1"></td></tr>
      <tr><td>Max tokens</td>
          <td><input type="number" name="maxTokens" min="1"></td></tr>
      <tr><td>Max page characters sent</td>
          <td><input type="number" name="maxPageChars" min="1000"></td></tr>
    </table>
    <button type="submit" class="ollama-set-submit">Save</button>
  `;

  // Populate current values
  for (const key of Object.keys(DEFAULTS)) {
    const el = form.querySelector(`[name="${key}"]`);
    if (el) el.value = getSetting(key);
  }

  form.onsubmit = e => {
    e.preventDefault();
    const data = new FormData(form);
    for (const [k, v] of data.entries()) {
      // Numbers need conversion
      if (["temperature", "maxTokens", "maxPageChars"].includes(k))
        setSetting(k, Number(v));
      else
        setSetting(k, v.trim());
    }
    alert("Settings saved! The next summarise will use the new values.");
    overlay.remove();
  };

  const overlay = document.createElement("div");
  overlay.className = "ollama-sum-modal";
  overlay.innerHTML = `<div class="ollama-sum-box"></div>`;
  overlay.querySelector(".ollama-sum-box").appendChild(form);
  document.body.appendChild(overlay);
}

/* ----------------------------------------------------------------------
   Core: extract visible page text (with size limit)
   ---------------------------------------------------------------------- */

function getPageText() {
  // Clone the document body, strip scripts/styles, then get innerText.
  const clone = document.body.cloneNode(true);
  // Remove script/style/meta/iframe elements that add noise
  //clone.querySelectorAll("script,style,meta,iframe,noscript").forEach(el => el.remove());
  clone.querySelectorAll("script,style,noscript").forEach(el => el.remove());

  const text = clone.innerText.trim().replace(/\s+/g, " ");
  const limit = getSetting("maxPageChars");
  if (text.length > limit) {
    return text.slice(0, limit) + "…";
  }
  return text;
}

/* ----------------------------------------------------------------------
   Core: call Ollama API
   ---------------------------------------------------------------------- */

async function callOllama(pageContent) {
  const endpoint = getSetting("endpoint");
  const apiKey = getSetting("apiKey");
  const model = getSetting("model");
  const temperature = getSetting("temperature");
  const maxTokens = getSetting("maxTokens");
  const systemPromptTemplate = getSetting("systemPrompt");

  // Insert the page content into the system prompt (or fallback to user message)
  const systemPrompt = systemPromptTemplate.replace("{{content}}", pageContent);

  // Ollama supports two flavors:
  //   1) /api/chat (legacy) – expects {model, messages, stream:false, options}
  //   2) OpenAI‑compatible /v1/chat/completions – expects messages array.
  // We'll try the /api/chat format first; if the endpoint ends with /v1/... we adapt.
  const isOpenAI = /\/v1\//i.test(endpoint);

  const body = isOpenAI
    ? {
        model,
        messages: [
          { role: "system", content: systemPrompt }
          // No user message – the whole prompt is already in system.
        ],
        temperature,
        max_tokens: maxTokens,
        stream: false
      }
    : {
        model,
        messages: [
          { role: "system", content: systemPrompt }
        ],
        stream: false,
        options: {
          temperature,
          num_ctx: maxTokens // Ollama treats this as max context tokens (optional)
        }
      };

  // GM_xmlhttpRequest is used because some sites have CSP that blocks fetch.
  return new Promise((resolve, reject) => {
    GM_xmlhttpRequest({
      method: "POST",
      url: endpoint,
      headers: {
        "Content-Type": "application/json",
        ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {})
      },
      data: JSON.stringify(body),
      timeout: 60_000,
      onload: resp => {
        if (resp.status >= 200 && resp.status < 300) {
          try {
            const json = JSON.parse(resp.responseText);
            // Different formats: Ollama returns {message:{content:...}}; OpenAI returns {choices:[{message:{content:...}}]}
            let content;
            if (json.message && json.message.content) {
              content = json.message.content;
            } else if (json.choices && json.choices[0]?.message?.content) {
              content = json.choices[0].message.content;
            } else if (json.response) { // legacy Ollama non‑stream response
              content = json.response;
            } else {
              throw new Error("Unexpected response format");
            }
            resolve(content.trim());
          } catch (e) {
            reject(`Failed to parse response: ${e.message}`);
          }
        } else {
          reject(`HTTP ${resp.status}: ${resp.statusText}\n${resp.responseText}`);
        }
      },
      onerror: err => reject(`Network error: ${err}`),
      ontimeout: () => reject("Request timed out")
    });
  });
}

/* ----------------------------------------------------------------------
   UI: the floating “Summarize” button
   ---------------------------------------------------------------------- */

function createSummarizeButton() {
  const btn = document.createElement("button");
  btn.textContent = "📰 Summarize page";
  btn.title = "Generate a short summary using Ollama";
  btn.style.position = "fixed";
  btn.style.bottom = "20px";
  btn.style.right = "20px";
  btn.style.zIndex = "999999";
  btn.style.padding = "0.6rem 1rem";
  btn.style.background = "#0066ff";
  btn.style.color = "#fff";
  btn.style.border = "none";
  btn.style.borderRadius = "4px";
  btn.style.boxShadow = "0 2px 6px rgba(0,0,0,.2)";
  btn.style.cursor = "pointer";
  btn.style.fontSize = "0.9rem";

  btn.onclick = async () => {
    btn.disabled = true;
    btn.textContent = "⏳ Summarising…";

    try {
      const pageText = getPageText();
      const summary = await callOllama(pageText);
      showModal(`
        <h2>Page summary</h2>
        <p>${escapeHtml(summary).replace(/\n/g, "<br>")}</p>
      `);
    } catch (e) {
      showModal(`
        <h2 style="color:#c00;">Error</h2>
        <pre>${escapeHtml(String(e))}</pre>
      `);
    } finally {
      btn.disabled = false;
      btn.textContent = "📰 Summarize page";
    }
  };

  document.body.appendChild(btn);
}

/* ----------------------------------------------------------------------
   Register menu commands (Tampermonkey/Greasemonkey)
   ---------------------------------------------------------------------- */

GM_registerMenuCommand("⚙️ Ollama Summarizer Settings", openSettingsDialog);
GM_registerMenuCommand("🧹 Reset to defaults", () => {
  if (confirm("Reset all Ollama Summarizer settings to default values?")) {
    for (const k of Object.keys(DEFAULTS)) GM_setValue(k, DEFAULTS[k]);
    alert("Defaults restored – reload the page for the changes to take effect.");
  }
});

/* ----------------------------------------------------------------------
   Initialise
   ---------------------------------------------------------------------- */

(function () {
  // Delay a little to avoid fighting with heavy‑weight SPAs that rewrite the DOM
  if (document.readyState === "complete" || document.readyState === "interactive") {
    setTimeout(createSummarizeButton, 1000);
  } else {
    window.addEventListener("load", () => setTimeout(createSummarizeButton, 1000));
  }
})();