const {
plugin: { store, scoped },
ui: {
openModal,
Button,
ButtonColors,
ButtonLooks,
TextBox,
TextArea,
ModalRoot,
ModalHeader,
ModalBody,
ModalFooter,
Header,
HeaderTags,
Text,
TextTags,
Divider,
showToast,
ToastColors,
injectCss,
},
} = 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 button ─────────────────────────────────
const removeCss = injectCss(`
[data-unsloth-chat-btn] {
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 32px !important;
height: 32px !important;
min-width: 32px !important;
border-radius: 8px !important;
cursor: pointer !important;
font-size: 16px !important;
font-weight: 700 !important;
color: var(--interactive-normal) !important;
background: transparent !important;
border: none !important;
padding: 0 !important;
transition: color 0.15s ease, background 0.15s ease !important;
line-height: 1 !important;
margin: 0 2px !important;
order: 10 !important;
}
[data-unsloth-chat-btn]:hover {
color: var(--interactive-hover) !important;
background: var(--background-modifier-hover) !important;
}
[data-unsloth-chat-btn] svg {
width: 20px !important;
height: 20px !important;
}
`);
scoped.onDispose(() => removeCss());
// ── 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 textarea = null;
for (const sel of selectors) {
textarea = document.querySelector(sel);
if (textarea) break;
}
if (!textarea) return false;
// Determine the native input element
let nativeInput;
const isContentEditable =
textarea.tagName !== "TEXTAREA" &&
(textarea.isContentEditable || textarea.getAttribute("contenteditable") === "true");
if (textarea.tagName === "TEXTAREA") {
// Classic Discord: direct textarea element
nativeInput = textarea;
} else if (isContentEditable) {
// Modern Discord: Slate/contenteditable div - use it directly
nativeInput = textarea;
} else {
// Look for a nested textarea (older layout)
nativeInput = textarea.querySelector("textarea");
}
if (!nativeInput) return false;
// Set the value
nativeInput.focus();
if (nativeInput.tagName === "TEXTAREA") {
// Classic textarea input
nativeInput.value = text;
} else {
// ContentEditable div (Slate editor / modern Discord)
// Clear existing content and insert new text
nativeInput.textContent = "";
nativeInput.focus();
// Use execCommand as fallback for Slate-based editors
try {
document.execCommand("insertText", false, text);
} catch (_) {
// Fallback: set textContent directly
nativeInput.textContent = text;
}
}
// Dispatch an input event so React picks up the change
const nativeInputEv = new Event("input", { bubbles: true, cancelable: true });
nativeInput.dispatchEvent(nativeInputEv);
// Also dispatch on the outer element if it differs and is contenteditable
if (textarea !== nativeInput && textarea.isContentEditable) {
textarea.textContent = text;
textarea.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 (