XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet
公开
关注 0 Fork 0 Star 1
UTF-8
/**
 * g4f extension — side panel logic.
 * Full conversation UI: history, streaming, model picker, context toggles,
 * image generation mode, context-menu presets and popup handoff.
 */

import { MESSAGE_TYPES, normalizeBaseUrl } from "../lib/constants.js";
import {
  getSettings, saveSettings, getConversations, saveConversations,
  getActiveConversationId, setActiveConversationId, newConversation,
} from "../lib/storage.js";
import { renderMarkdown } from "../lib/markdown.js";
import { toast, bindCopyHandlers, escapeHtml } from "../lib/ui.js";

const $ = (sel) => document.querySelector(sel);

let settings = null;
let conversation = null;
let conversations = [];
let streamId = null;
let port = null;
let generating = false;
let imageMode = false;

init();

async function init() {
  settings = await getSettings();
  bindCopyHandlers(document, () => toast($("#toast-root"), "Copied"));

  conversations = await getConversations();
  const activeId = await getActiveConversationId();
  conversation =
    conversations.find((c) => c.id === activeId) || createConversation();

  wireEvents();
  loadModels();
  checkHealth();
  renderMessages();
  refreshAccountUI();

  await consumePreset();   // context-menu handoff
  await consumeHandoff();  // popup handoff
}

/* ------------------------------------------------------------------ */
/* Account (g4f.space OAuth) & cloud sync                              */
/* ------------------------------------------------------------------ */

async function refreshAccountUI() {
  const res = await chrome.runtime.sendMessage({ type: MESSAGE_TYPES.OAUTH_STATUS });
  const signedIn = !!res?.signedIn;
  const user = res?.user;

  $("#account-btn").hidden = signedIn;
  $("#sync-btn").hidden = !signedIn;
  $("#account-bar").hidden = !signedIn;
  if (signedIn && user) {
    $("#account-name").textContent = user.name || user.username || user.id;
    $("#account-tier").textContent = user.tier ? String(user.tier) : "";
    const avatar = $("#account-avatar");
    if (user.avatar) { avatar.src = user.avatar; avatar.hidden = false; }
    else avatar.hidden = true;
  }
}

async function startSignIn() {
  const btn = $("#account-btn");
  btn.disabled = true;
  btn.textContent = "…";
  try {
    const res = await chrome.runtime.sendMessage({ type: MESSAGE_TYPES.OAUTH_START });
    if (!res?.ok) throw new Error(res?.error || "Sign-in failed");
    toast($("#toast-root"), "Signed in as " + (res.user?.name || res.user?.id), "ok");
    await refreshAccountUI();
    await syncNow(true);
  } catch (e) {
    toast($("#toast-root"), e.message || String(e), "error");
  } finally {
    btn.disabled = false;
    btn.textContent = "⇧";
  }
}

async function syncNow(silent = false) {
  const btn = $("#sync-btn");
  btn.classList.add("spinning");
  try {
    const res = await chrome.runtime.sendMessage({ type: MESSAGE_TYPES.SYNC_NOW });
    if (!res?.ok) throw new Error(res?.error || "Sync failed");
    if (res.pulled > 0) {
      conversations = await getConversations();
      const activeId = await getActiveConversationId();
      conversation = conversations.find((c) => c.id === activeId) || conversation;
      renderMessages();
    }
    if (!silent) {
      toast($("#toast-root"), `Synced: pushed ${res.pushed}, pulled ${res.pulled}`, "ok");
    }
  } catch (e) {
    if (!silent) toast($("#toast-root"), e.message || String(e), "error");
  } finally {
    btn.classList.remove("spinning");
  }
}

/* ------------------------------------------------------------------ */
/* Conversation management                                             */
/* ------------------------------------------------------------------ */

function createConversation() {
  const c = newConversation();
  conversations.unshift(c);
  saveConversations(conversations);
  setActiveConversationId(c.id);
  return c;
}

function touchConversation() {
  conversation.updatedAt = Date.now();
  const idx = conversations.findIndex((c) => c.id === conversation.id);
  if (idx >= 0) conversations[idx] = conversation;
  saveConversations(conversations);
  setActiveConversationId(conversation.id);
}

function resetChat() {
  conversation = createConversation();
  renderMessages();
}

/* ------------------------------------------------------------------ */
/* Events                                                              */
/* ------------------------------------------------------------------ */

function wireEvents() {
  $("#new-chat").addEventListener("click", resetChat);
  $("#open-options").addEventListener("click", () => chrome.runtime.openOptionsPage());

  const input = $("#input");
  const send = $("#send");
  const stop = $("#stop");

  send.addEventListener("click", () => submit());
  stop.addEventListener("click", () => {
    if (streamId) {
      chrome.runtime.sendMessage({ type: MESSAGE_TYPES.CHAT_ABORT, streamId });
    }
  });

  input.addEventListener("keydown", (e) => {
    if (e.key === "Enter" && !e.shiftKey && settings.sendOnEnter) {
      e.preventDefault();
      submit();
    }
  });
  input.addEventListener("input", () => {
    input.style.height = "auto";
    input.style.height = Math.min(input.scrollHeight, 140) + "px";
  });

  // Image mode: prefix prompt with "/img "
  input.addEventListener("input", () => {
    imageMode = input.value.startsWith("/img ");
  });

  $("#include-page").addEventListener("change", (e) =>
    saveSettings({ includePageContext: e.target.checked })
  );
  $("#include-page").checked = !!settings.includePageContext;

  // Model picker
  $("#model-select").addEventListener("change", (e) => {
    saveSettings({ model: e.target.value });
    toast($("#toast-root"), "Model: " + (e.target.value || "default"));
  });
  // Click-to-retry when the model list failed to load.
  $("#model-select").addEventListener("click", (e) => {
    if (e.target.classList.contains("error")) {
      e.preventDefault();
      loadModels();
    }
  });

  // Account & sync
  $("#account-btn").addEventListener("click", startSignIn);
  $("#account-logout").addEventListener("click", async () => {
    await chrome.runtime.sendMessage({ type: MESSAGE_TYPES.OAUTH_LOGOUT });
    await refreshAccountUI();
    toast($("#toast-root"), "Signed out");
  });
  $("#sync-btn").addEventListener("click", () => syncNow());
}

async function loadModels() {
  const select = $("#model-select");
  select.classList.remove("error");
  select.innerHTML = '<option value="">Loading models…</option>';
  try {
    const res = await chrome.runtime.sendMessage({ type: MESSAGE_TYPES.MODELS });
    if (!res?.ok) throw new Error(res?.error || "No response from background");
    const models = res.data.filter((m) => !m.provider);
    select.innerHTML = '<option value="">Default model</option>';
    for (const m of models) {
      const opt = document.createElement("option");
      opt.value = m.id;          // full id sent to the server
      opt.textContent = m.label || m.id; // human-readable label
      if (m.id === settings.model) opt.selected = true;
      select.appendChild(opt);
    }
    if (!models.length) {
      select.innerHTML = '<option value="">No models available</option>';
      select.classList.add("error");
    } else if (settings.model) {
      select.value = settings.model; // show saved pick in the closed box
    }
  } catch (e) {
    // Keep the usable "Default model" entry and surface WHY it failed
    // (hover the select for details; the server host is included).
    console.warn("[g4f] loadModels failed:", e);
    let host = settings.serverUrl;
    try { host = new URL(normalizeBaseUrl(settings.serverUrl)).host; } catch { /* keep raw */ }
    select.innerHTML = '<option value="">Default model</option>';
    select.title = `Model list unavailable (${host}): ${e?.message || e} — click to retry`;
    select.classList.add("error");
  }
}

async function checkHealth() {
  const el = $("#conn-status");
  try {
    const res = await chrome.runtime.sendMessage({ type: MESSAGE_TYPES.HEALTH });
    el.classList.toggle("ok", !!res?.ok);
    el.classList.toggle("bad", !res?.ok);
    el.title = res?.ok ? "Server reachable" : "Server unreachable";
  } catch {
    el.classList.add("bad");
  }
}

/* ------------------------------------------------------------------ */
/* Submit / stream                                                     */
/* ------------------------------------------------------------------ */

async function submit() {
  const input = $("#input");
  let text = input.value.trim();
  if (!text || generating) return;

  const isImage = text.startsWith("/img ");
  if (isImage) {
    input.value = "";
    input.style.height = "auto";
    return runImage(text.slice(5).trim());
  }

  const messages = [];
  if (settings.systemPrompt) {
    messages.push({ role: "system", content: settings.systemPrompt });
  }
  for (const m of conversation.messages) {
    messages.push({ role: m.role, content: m.content });
  }

  // Context enrichment
  const extras = [];
  if ($("#include-page").checked) {
    const page = await chrome.runtime.sendMessage({ type: MESSAGE_TYPES.GET_PAGE });
    if (page?.text) {
      extras.push(
        `Current page: "${page.title}" (${page.url})\n\n---\n${page.text.slice(0, settings.pageContextLimit)}\n---`
      );
    }
  }
  if ($("#include-selection").checked) {
    const sel = await chrome.runtime.sendMessage({ type: MESSAGE_TYPES.GET_SELECTION });
    if (sel?.text?.trim()) {
      extras.push("Selected text:\n" + sel.text.trim().slice(0, settings.pageContextLimit));
    }
  }

  const userContent = extras.length ? extras.join("\n\n") + "\n\n" + text : text;
  messages.push({ role: "user", content: userContent });

  conversation.messages.push({ role: "user", content: text });
  if (conversation.title === "New chat") {
    conversation.title = text.slice(0, 42) + (text.length > 42 ? "…" : "");
  }
  appendMessageEl({ role: "user", content: text });
  touchConversation();

  input.value = "";
  input.style.height = "auto";
  setGenerating(true);

  conversation.messages.push({ role: "assistant", content: "" });
  const assistantEl = appendMessageEl({ role: "assistant", content: "" }, true);

  streamId = "panel-" + Date.now();
  port = chrome.runtime.connect({ name: "g4f:" + streamId });
  port.onMessage.addListener((msg) => onStream(msg, assistantEl));
  port.onDisconnect.addListener(() => (port = null));

  await chrome.runtime.sendMessage({
    type: MESSAGE_TYPES.CHAT,
    streamId,
    messages,
    overrides: { model: $("#model-select").value || undefined },
  });
}

function onStream(msg, assistantEl) {
  if (msg.type === MESSAGE_TYPES.STREAM_CHUNK) {
    const last = conversation.messages[conversation.messages.length - 1];
    if (last?.role === "assistant") {
      last.content = msg.full;
      const body = assistantEl.querySelector(".msg-body");
      body.innerHTML = renderMarkdown(msg.full);
      scrollBottom();
    }
  } else if (msg.type === MESSAGE_TYPES.STREAM_DONE) {
    const last = conversation.messages[conversation.messages.length - 1];
    if (last?.role === "assistant" && !last.content) {
      last.content = "*(empty response)*";
      assistantEl.querySelector(".msg-body").textContent = "(empty response)";
    }
    touchConversation();
    setGenerating(false);
    // Auto-push the finished conversation to the user's secret workspace.
    syncNow(true);
  } else if (msg.type === MESSAGE_TYPES.STREAM_ERROR) {
    assistantEl.querySelector(".msg-body").innerHTML =
      `<span class="error">⚠ ${escapeHtml(msg.error || "Request failed")}</span>`;
    const last = conversation.messages[conversation.messages.length - 1];
    if (last?.role === "assistant") last.content = "Error: " + (msg.error || "");
    touchConversation();
    setGenerating(false);
  }
}

function setGenerating(on) {
  generating = on;
  $("#send").hidden = on;
  $("#stop").hidden = !on;
}

function scrollBottom() {
  const msgs = $("#messages");
  msgs.scrollTop = msgs.scrollHeight;
}

/* ------------------------------------------------------------------ */
/* Image generation                                                    */
/* ------------------------------------------------------------------ */

async function runImage(prompt) {
  if (!prompt) return;
  appendMessageEl({ role: "user", content: "🖼 " + prompt });
  const container = $("#images");
  const placeholder = document.createElement("div");
  placeholder.className = "img-placeholder";
  placeholder.innerHTML = '<span class="spinner"></span> Generating…';
  container.prepend(placeholder);

  streamId = "img-" + Date.now();
  port = chrome.runtime.connect({ name: "g4f:" + streamId });
  port.onMessage.addListener((msg) => {
    if (msg.type === MESSAGE_TYPES.STREAM_DONE) {
      placeholder.remove();
      for (const url of msg.urls || []) {
        const img = document.createElement("img");
        img.src = url;
        img.className = "generated-img";
        img.title = "Click to open";
        img.addEventListener("click", () => window.open(url, "_blank"));
        container.prepend(img);
      }
      if (!msg.urls?.length) {
        toast($("#toast-root"), "No images returned", "warn");
      }
    } else if (msg.type === MESSAGE_TYPES.STREAM_ERROR) {
      placeholder.remove();
      toast($("#toast-root"), msg.error || "Image failed", "error");
    }
  });
  await chrome.runtime.sendMessage({
    type: MESSAGE_TYPES.IMAGE,
    streamId,
    prompt,
  });
}

/* ------------------------------------------------------------------ */
/* Rendering                                                           */
/* ------------------------------------------------------------------ */

function renderMessages() {
  const root = $("#messages");
  root.innerHTML = "";
  if (!conversation.messages.length) {
    root.innerHTML = `
      <div class="welcome">
        <img src="../icons/icon48.png" alt="" width="42" height="42" />
        <h1>Free AI, in your sidebar</h1>
        <p>Ask anything. Use <strong>Ask g4f</strong> in the right-click menu on any selection, or enable “page context” to chat about the current tab.</p>
        <div class="welcome-tips">
          <span class="tip">/img a red cube — generate images</span>
          <span class="tip">Alt+Shift+S — toggle panel</span>
        </div>
      </div>`;
    return;
  }
  for (const m of conversation.messages) appendMessageEl(m);
  scrollBottom();
}

function appendMessageEl(message, streaming = false) {
  const root = $("#messages");
  root.querySelector(".welcome")?.remove();

  const wrap = document.createElement("div");
  wrap.className = `msg msg-${message.role}` + (streaming ? " streaming" : "");

  const actions = document.createElement("div");
  actions.className = "msg-actions";

  if (message.role === "assistant" && message.content) {
    const copy = document.createElement("button");
    copy.className = "copy-btn mini";
    copy.textContent = "⧉";
    copy.title = "Copy";
    copy.setAttribute("data-copy", message.content);
    actions.appendChild(copy);
  }

  const body = document.createElement("div");
  body.className = "msg-body";
  body.innerHTML = message.content ? renderMarkdown(message.content) : '<span class="spinner"></span>';

  wrap.appendChild(actions);
  wrap.appendChild(body);
  root.appendChild(wrap);
  scrollBottom();
  return wrap;
}

/* ------------------------------------------------------------------ */
/* Handoffs (context menu / popup)                                     */
/* ------------------------------------------------------------------ */

async function consumePreset() {
  const { g4fPreset } = await chrome.storage.session.get("g4fPreset");
  if (!g4fPreset) return;
  await chrome.storage.session.remove("g4fPreset");
  if (Date.now() - (g4fPreset.ts || 0) > 30000) return; // stale

  const prompts = {
    ask: (t) => t,
    explain: (t) => `Explain this in simple terms:\n\n${t}`,
    translate: (t) => `Translate the following text to English. Only output the translation:\n\n${t}`,
    summarize: (t) => `Summarize the following page content in a few bullet points:\n\n${t}`,
  };
  const fn = prompts[g4fPreset.type] || prompts.ask;
  const text = (g4fPreset.text || "").trim();
  if (!text) return;

  const input = $("#input");
  input.value = fn(text.slice(0, settings.pageContextLimit));
  input.dispatchEvent(new Event("input"));
  submit();
}

async function consumeHandoff() {
  const { g4fHandoff } = await chrome.storage.session.get("g4fHandoff");
  if (!g4fHandoff) return;
  await chrome.storage.session.remove("g4fHandoff");
  if (Date.now() - (g4fHandoff.ts || 0) > 60000) return;

  for (const m of g4fHandoff.messages || []) {
    conversation.messages.push(m);
    appendMessageEl(m);
  }
  touchConversation();
}