const {
plugin: { store, scoped },
ui: {
openModal,
Button,
ButtonColors,
ButtonLooks,
TextBox,
TextArea,
ModalRoot,
ModalHeader,
ModalBody,
ModalFooter,
Header,
HeaderTags,
Text,
TextTags,
Divider,
showToast,
ToastColors,
injectCss,
},
flux: { stores },
} = shelter;
const UNSLOTH_DEFAULT_URL = "http://localhost:8000";
const UNSLOTH_MODEL = "default";
// ── Default settings ────────────────────────────────────────────
store.apiUrl ??= UNSLOTH_DEFAULT_URL;
store.apiKey ??= "";
store.model ??= UNSLOTH_MODEL;
store.maxTokens ??= 1024;
store.temperature ??= 0.7;
// ── Injected CSS for the menu items ─────────────────────────────
const removeCss = injectCss(`
[data-unsloth-menu-item] {
display: flex !important;
align-items: center !important;
gap: 8px !important;
padding: 6px 10px !important;
margin: 2px 6px !important;
cursor: pointer !important;
border-radius: 3px !important;
color: var(--interactive-normal) !important;
font-size: 14px !important;
line-height: 18px !important;
font-weight: 500 !important;
}
[data-unsloth-menu-item]:hover {
background: var(--background-modifier-hover) !important;
color: var(--interactive-hover) !important;
}
[data-unsloth-menu-item] svg {
width: 18px !important;
height: 18px !important;
flex-shrink: 0 !important;
}
`);
scoped.onDispose(() => removeCss());
// ── SVG icons ───────────────────────────────────────────────────
const SPARKLE_ICON = ``;
const REPLY_ICON = ``;
// ── Helper: find and fill the message input ─────────────────────
function setMessageInput(text) {
// Broad selectors for Discord's chat input (textarea or contenteditable div)
const selectors = [
// Modern Discord: Slate-based contenteditable div
'[class*="channelTextArea"] div[role="textbox"][contenteditable="true"]',
'[class*="channelTextArea"] [data-slate-editor="true"]',
// Classic Discord: textarea inside channelTextArea
'[class*="channelTextArea"] textarea',
// Fallback: role="textbox" anywhere inside channelTextArea
'[class*="channelTextArea"] [role="textbox"]',
// Even broader fallbacks (no main/chat prefix)
'main [class*="channelTextArea"] textarea',
'main [class*="channelTextArea"] [role="textbox"]',
'[class*="chat"] [class*="channelTextArea"] textarea',
'[class*="chat"] [class*="channelTextArea"] [role="textbox"]',
];
let editorEl = null;
for (const sel of selectors) {
editorEl = document.querySelector(sel);
if (editorEl) break;
}
if (!editorEl) return false;
// Determine the native input element
let nativeInput;
const isContentEditable =
editorEl.tagName !== "TEXTAREA" &&
(editorEl.isContentEditable || editorEl.getAttribute("contenteditable") === "true");
if (editorEl.tagName === "TEXTAREA") {
// Classic Discord: direct textarea element
nativeInput = editorEl;
} else if (isContentEditable) {
// Modern Discord: Slate/contenteditable div - use it directly
nativeInput = editorEl;
} else {
// Look for a nested textarea (older layout)
nativeInput = editorEl.querySelector("textarea");
}
if (!nativeInput) return false;
// ── Set the value ───────────────────────────────────────────
nativeInput.focus();
if (nativeInput.tagName === "TEXTAREA") {
// Classic textarea input
nativeInput.value = text;
nativeInput.dispatchEvent(new Event("input", { bubbles: true, cancelable: true }));
} else {
// ContentEditable div — modern Discord Slate editor
// Slate listens for "beforeinput" events, not plain DOM mutations.
// We select all existing content, then dispatch beforeinput so
// Slate handles the replacement through its own React pipeline.
// 1. Select all existing content so the insert replaces everything
const sel = window.getSelection();
if (sel) {
try {
const range = document.createRange();
range.selectNodeContents(nativeInput);
sel.removeAllRanges();
sel.addRange(range);
} catch (_) { /* selection API may fail on some elements */ }
}
// 2. Dispatch beforeinput — this is what Slate's withReact
// plugin hooks into to update the editor's React state.
// dispatchEvent returns FALSE when the event was cancelled
// (Slate calls preventDefault when it handles the event).
// TRUE means nobody cancelled -> we need the fallback.
let needsFallback = true;
try {
const notCancelled = nativeInput.dispatchEvent(new InputEvent("beforeinput", {
inputType: "insertText",
data: text,
bubbles: true,
cancelable: true,
}));
needsFallback = notCancelled;
} catch (_) {
// InputEvent constructor may not be available (old Electron)
}
// 3. Fallback: if beforeinput wasn't cancelled by any handler
// use execCommand which works on older Discord layouts.
if (needsFallback) {
try {
document.execCommand("selectAll", false, null);
document.execCommand("insertText", false, text);
} catch (_) {
// Last resort: set textContent directly
nativeInput.textContent = text;
}
}
// 4. Always dispatch input — React-controlled components often
// listen for this in addition to beforeinput.
nativeInput.dispatchEvent(new Event("input", { bubbles: true }));
}
return true;
}
// ── API call ────────────────────────────────────────────────────
async function callUnsloth(prompt) {
const url = `${store.apiUrl.replace(/\/+$/, "")}/v1/chat/completions`;
const headers = { "Content-Type": "application/json" };
if (store.apiKey) {
headers["Authorization"] = `Bearer ${store.apiKey}`;
}
const body = {
model: store.model,
messages: [{ role: "user", content: prompt }],
stream: false,
max_tokens: store.maxTokens,
temperature: store.temperature,
};
let res;
let fetchError = null;
try {
res = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
});
} catch (e) {
fetchError = e;
}
if (fetchError) {
// Network-level error (e.g. Failed to fetch, CORS, DNS, etc.)
throw new DetailedApiError({
message: fetchError.message || "Failed to fetch",
url,
prompt,
model: store.model,
apiUrl: store.apiUrl,
status: null,
responseBody: null,
timestamp: new Date().toISOString(),
cause: fetchError,
});
}
if (!res.ok) {
let responseBody = null;
let detail = `API returned ${res.status}`;
try {
responseBody = await res.text();
try {
const err = JSON.parse(responseBody);
detail = err.error?.message || err.detail || detail;
} catch {}
} catch {}
throw new DetailedApiError({
message: detail,
url,
prompt,
model: store.model,
apiUrl: store.apiUrl,
status: res.status,
responseBody,
timestamp: new Date().toISOString(),
cause: null,
});
}
const data = await res.json();
const text = data?.choices?.[0]?.message?.content || "";
return text;
}
// ── Detailed error class ─────────────────────────────────────────
class DetailedApiError extends Error {
constructor(details) {
super(details.message);
this.name = "DetailedApiError";
this.url = details.url;
this.prompt = details.prompt;
this.model = details.model;
this.apiUrl = details.apiUrl;
this.status = details.status;
this.responseBody = details.responseBody;
this.timestamp = details.timestamp;
this.cause = details.cause;
}
}
// ── Error detail modal ──────────────────────────────────────────
const ERROR_MODAL_CLASS = "uns-errmodal";
// Injected CSS for the error modal — wider modal so all detail fits
const removeErrCss = injectCss(`
.${ERROR_MODAL_CLASS} {
width: 620px !important;
max-width: 90vw !important;
}
`);
scoped.onDispose(() => removeErrCss());
function ErrorDetailModal(props) {
const error = props.error;
return (
Unsloth Chat - Error Details
{/* Error message */}
{/* Status code */}
{error.status !== null ? error.status : "N/A (network error)"}
{/* API URL used */}
{/* Configured API URL */}
{/* Model */}
{error.model}
{/* Prompt */}
{/* Response body */}
{error.responseBody && (
)}
{/* Cause (network error details) */}
{error.cause && (
)}
{/* Timestamp */}
Occurred at: {error.timestamp}
);
}
// ── The prompt modal ────────────────────────────────────────────
function PromptModal(props) {
const [prompt, setPrompt] = shelter.solid.createSignal(props.initialPrompt || "");
const [loading, setLoading] = shelter.solid.createSignal(false);
const [error, setError] = shelter.solid.createSignal("");
let inputRef = undefined;
let confirmRef = undefined;
shelter.solid.onMount(() => {
if (inputRef) inputRef.focus();
});
async function handleSubmit() {
const p = prompt().trim();
if (!p) return;
setLoading(true);
setError("");
try {
const result = await callUnsloth(p);
if (result) {
const ok = setMessageInput(result);
if (ok) {
showToast({
title: "Unsloth Chat",
content: "Response inserted into message input!",
color: ToastColors.SUCCESS,
});
} else {
showToast({
title: "Unsloth Chat",
content: "Could not find message input. Response copied to clipboard.",
color: ToastColors.WARNING,
});
try {
await navigator.clipboard.writeText(result);
} catch {}
}
} else {
setError("The model returned an empty response.");
}
props.close();
} catch (e) {
if (e instanceof DetailedApiError) {
openModal((p) => );
} else {
setError(e.message || "An error occurred");
}
} finally {
setLoading(false);
}
}
function handleKeyDown(e) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
if (e.key === "Escape") {
props.close();
}
}
return (
Unsloth Studio
{error() && (
{error()}
)}
);
}
// ── Flux helpers: get current channel & messages ─────────────────
function getCurrentChannelId() {
try {
// Try SelectedChannelStore first (most common)
const chan = stores.SelectedChannelStore || stores.ChannelStore;
if (chan && typeof chan.getChannelId === "function") {
return chan.getChannelId();
}
} catch (_) {}
// Fallback: parse URL
try {
const m = window.location.pathname.match(/\/channels\/(\d+|@me)\/(\d+)/);
if (m) return m[2];
} catch (_) {}
return null;
}
function getRecentMessages(count = 10) {
const channelId = getCurrentChannelId();
if (!channelId) return [];
try {
const MessageStore = stores.MessageStore;
if (!MessageStore || typeof MessageStore.getMessages !== "function") return [];
const coll = MessageStore.getMessages(channelId);
if (!coll) return [];
const arr = typeof coll.toArray === "function" ? coll.toArray() : Array.from(coll);
return arr.filter(m => m && m.content != null).slice(-count);
} catch (_) {
return [];
}
}
// ── Suggest reply from recent messages ──────────────────────────
async function suggestReply() {
const messages = getRecentMessages(10);
if (messages.length === 0) {
showToast({
title: "Unsloth Chat",
content: "No recent messages found in this channel.",
color: ToastColors.WARNING,
});
return;
}
// Build conversation transcript
const convo = messages.map(m => {
const author = m.author?.username || m.author?.globalName || "Unknown";
const content = (m.content || "").trim() || "(attachment)";
return `${author}: ${content}`;
}).join("\n");
const prompt =
"The following is a recent conversation in a Discord channel. " +
"Write a short, natural, contextually appropriate reply that " +
"continues this conversation. Just write the reply message, " +
"nothing else.\n\n---\n" + convo + "\n---\n\nReply:";
try {
const result = await callUnsloth(prompt);
if (result) {
const ok = setMessageInput(result);
if (ok) {
showToast({
title: "Unsloth Chat",
content: "Suggested reply inserted!",
color: ToastColors.SUCCESS,
});
} else {
showToast({
title: "Unsloth Chat",
content: "Could not find message input. Response copied to clipboard.",
color: ToastColors.WARNING,
});
try { await navigator.clipboard.writeText(result); } catch {}
}
} else {
showToast({
title: "Unsloth Chat",
content: "The model returned an empty response.",
color: ToastColors.WARNING,
});
}
} catch (e) {
if (e instanceof DetailedApiError) {
openModal((p) => );
} else {
showToast({
title: "Unsloth Chat",
content: e.message || "An error occurred",
color: ToastColors.DANGER || ToastColors.WARNING,
});
}
}
}
// ── Menu item factory ───────────────────────────────────────────
function createMenuItem(label, iconSvg, onClick) {
const el = document.createElement("div");
el.setAttribute("data-unsloth-menu-item", "");
el.setAttribute("role", "menuitem");
el.setAttribute("tabindex", "0");
el.innerHTML = iconSvg + `${label}`;
el.addEventListener("click", (e) => {
e.stopPropagation();
onClick();
});
return el;
}
// ── Identify the + popout and inject items ──────────────────────
function isAttachMenu(scroller) {
// Check menu item text for keywords unique to the + popout
const items = scroller.querySelectorAll('[role="menuitem"], [class*="item"]');
for (const item of items) {
const t = (item.textContent || "").toLowerCase();
if (/upload|file|thread|create|attach|app|integration|gif|sticker|poll/i.test(t)) {
return true;
}
}
// Fallback: is the popout positioned near the chat input area?
const menuRoot = scroller.closest('[class*="menu"]') || scroller.closest('[class*="popout"]');
if (menuRoot) {
const cta = document.querySelector('[class*="channelTextArea"]');
if (cta) {
const mr = menuRoot.getBoundingClientRect();
const cr = cta.getBoundingClientRect();
if (mr.bottom >= cr.top - 20 && mr.top <= cr.bottom + 20) return true;
}
}
return false;
}
function addItemsToScroller(scroller) {
if (!scroller || scroller.querySelector("[data-unsloth-menu-item]")) return;
const divider = document.createElement("div");
divider.style.cssText =
"margin:4px 6px;border-top:1px solid var(--background-modifier-accent);";
const askAi = createMenuItem("Ask AI", SPARKLE_ICON, () => {
openModal((p) => );
});
const suggest = createMenuItem("Suggest reply", REPLY_ICON, () => {
suggestReply();
});
scroller.appendChild(divider);
scroller.appendChild(askAi);
scroller.appendChild(suggest);
}
// ── Strategy 1: MutationObserver watches for any menu popout ────
const menuObserver = new MutationObserver((mutations) => {
for (const mut of mutations) {
for (const node of mut.addedNodes) {
if (node.nodeType !== Node.ELEMENT_NODE) continue;
// The added node may be the menu itself …
if (node.matches && node.matches('[class*="menu"]')) {
const sc = node.querySelector('[class*="scroller"]');
if (sc && isAttachMenu(sc)) addItemsToScroller(sc);
}
// … or it may contain a menu subtree deeper inside (portals)
if (node.querySelectorAll) {
node.querySelectorAll('[class*="menu"]').forEach(menu => {
const sc = menu.querySelector('[class*="scroller"]');
if (sc && isAttachMenu(sc)) addItemsToScroller(sc);
});
}
}
}
});
menuObserver.observe(document.body, { childList: true, subtree: true });
scoped.onDispose(() => menuObserver.disconnect());
// ── Strategy 2: hook the + button click for faster injection ────
function hookAttachButton(btn) {
if (btn.dataset.unslothHooked) return;
btn.dataset.unslothHooked = "1";
btn.addEventListener("click", () => {
let tries = 0;
const poll = () => {
if (tries++ > 15) return;
const scrollers = document.querySelectorAll('[class*="menu"] [class*="scroller"]');
for (const sc of scrollers) {
if (!sc.querySelector("[data-unsloth-menu-item]") && isAttachMenu(sc)) {
addItemsToScroller(sc);
return;
}
}
setTimeout(poll, 40);
};
setTimeout(poll, 30);
});
}
// Multiple possible selectors for the + button (Discord changes these)
scoped.observeDom(
[
'[class*="channelTextArea"] [class*="attachButton"]',
'[class*="channelTextArea"] [class*="attachWrapper"] > button',
'[class*="channelTextArea"] button[class*="attach"]',
'[class*="channelTextArea"] [aria-label*="ttach"]',
'[class*="channelTextArea"] [class*="uploadInput"]',
].join(", "),
hookAttachButton,
);
// ── Export settings component ───────────────────────────────────
export function settings() {
const [apiUrl, setApiUrl] = shelter.solid.createSignal(store.apiUrl);
const [apiKey, setApiKey] = shelter.solid.createSignal(store.apiKey);
const [model, setModel] = shelter.solid.createSignal(store.model);
const [maxTokens, setMaxTokens] = shelter.solid.createSignal(String(store.maxTokens));
const [temperature, setTemperature] = shelter.solid.createSignal(String(store.temperature));
function saveApiUrl(v) {
setApiUrl(v);
store.apiUrl = v;
}
function saveApiKey(v) {
setApiKey(v);
store.apiKey = v;
}
function saveModel(v) {
setModel(v);
store.model = v;
}
function saveMaxTokens(v) {
setMaxTokens(v);
const n = Number(v);
if (!isNaN(n) && n > 0) store.maxTokens = n;
}
function saveTemperature(v) {
setTemperature(v);
const n = Number(v);
if (!isNaN(n) && n >= 0 && n <= 2) store.temperature = n;
}
return (
Unsloth Studio Connection
API URL
The URL of your running Unsloth Studio instance.
API Key (optional)
Your Unsloth Studio API key/token for authenticated access.
Model
Max Tokens
Temperature (0.0 - 2.0)
);
}