Files
discord-plugins/plugins/unsloth-chat/index.jsx
2026-05-19 02:13:10 +03:00

591 lines
18 KiB
JavaScript

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) {
// Find the Discord chat textarea
const textarea = document.querySelector(
'main [class*="channelTextArea"] textarea, ' +
'main [class*="channelTextArea"] [role="textbox"], ' +
'[class*="chat"] [class*="channelTextArea"] textarea, ' +
'[class*="chat"] [class*="channelTextArea"] [role="textbox"]'
);
if (!textarea) return false;
// The native textarea (or contenteditable div) inside
const nativeInput = textarea.tagName === "TEXTAREA"
? textarea
: textarea.querySelector("textarea");
if (!nativeInput) return false;
// Set the native value
nativeInput.focus();
nativeInput.value = 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's a 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 ──────────────────────────────────────────
function ErrorDetailModal(props) {
const error = props.error;
return (
<ModalRoot>
<ModalHeader close={props.close}>Unsloth Chat - Error Details</ModalHeader>
<ModalBody>
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
{/* Error message */}
<div>
<Header tag={HeaderTags.HeadingSM} margin={false}>Error</Header>
<div style={{
marginTop: "4px",
padding: "8px 12px",
background: "var(--info-danger-background)",
color: "var(--text-danger)",
borderRadius: "4px",
fontSize: "14px",
wordBreak: "break-word",
}}>
{error.message}
</div>
</div>
{/* Status code */}
<div>
<Header tag={HeaderTags.HeadingSM} margin={false}>HTTP Status</Header>
<Text tag={TextTags.textSM} style={{ marginTop: "4px", display: "block" }}>
{error.status !== null ? error.status : "N/A (network error)"}
</Text>
</div>
{/* API URL used */}
<div>
<Header tag={HeaderTags.HeadingSM} margin={false}>Request URL</Header>
<Text tag={TextTags.textSM} style={{
marginTop: "4px",
display: "block",
wordBreak: "break-all",
fontFamily: "var(--font-code, monospace)",
fontSize: "12px",
}}>
{error.url}
</Text>
</div>
{/* Configured API URL */}
<div>
<Header tag={HeaderTags.HeadingSM} margin={false}>Configured API URL</Header>
<Text tag={TextTags.textSM} style={{
marginTop: "4px",
display: "block",
wordBreak: "break-all",
fontFamily: "var(--font-code, monospace)",
fontSize: "12px",
}}>
{error.apiUrl}
</Text>
</div>
{/* Model */}
<div>
<Header tag={HeaderTags.HeadingSM} margin={false}>Model</Header>
<Text tag={TextTags.textSM} style={{ marginTop: "4px", display: "block" }}>
{error.model}
</Text>
</div>
{/* Prompt */}
<div>
<Header tag={HeaderTags.HeadingSM} margin={false}>Prompt</Header>
<div style={{
marginTop: "4px",
padding: "8px 12px",
background: "var(--background-secondary)",
borderRadius: "4px",
fontSize: "13px",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
maxHeight: "120px",
overflowY: "auto",
}}>
{error.prompt}
</div>
</div>
{/* Response body */}
{error.responseBody && (
<div>
<Header tag={HeaderTags.HeadingSM} margin={false}>Response Body</Header>
<div style={{
marginTop: "4px",
padding: "8px 12px",
background: "var(--background-secondary)",
borderRadius: "4px",
fontSize: "12px",
fontFamily: "var(--font-code, monospace)",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
maxHeight: "200px",
overflowY: "auto",
}}>
{error.responseBody}
</div>
</div>
)}
{/* Cause (network error details) */}
{error.cause && (
<div>
<Header tag={HeaderTags.HeadingSM} margin={false}>Network Error</Header>
<div style={{
marginTop: "4px",
padding: "8px 12px",
background: "var(--background-secondary)",
borderRadius: "4px",
fontSize: "12px",
fontFamily: "var(--font-code, monospace)",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}>
{error.cause.toString()}
</div>
</div>
)}
{/* Timestamp */}
<div>
<Text tag={TextTags.textXS} style={{ color: "var(--text-muted)", display: "block" }}>
Occurred at: {error.timestamp}
</Text>
</div>
</div>
</ModalBody>
<ModalFooter>
<div style={{ display: "flex", gap: "8px", justifyContent: "flex-end", width: "100%" }}>
<Button
look={ButtonLooks.FILLED}
color={ButtonColors.PRIMARY}
onClick={props.close}
>
Close
</Button>
</div>
</ModalFooter>
</ModalRoot>
);
}
// ── The prompt modal ────────────────────────────────────────────
function PromptModal(props) {
const [prompt, setPrompt] = shelter.solid.createSignal("");
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) => <ErrorDetailModal close={p.close} error={e} />);
} 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 (
<ModalRoot>
<ModalHeader close={props.close}>Unsloth Studio</ModalHeader>
<ModalBody>
<Header tag={HeaderTags.HeadingSM} margin={false}>
Prompt
</Header>
<div style={{ marginTop: "8px" }}>
<TextArea
ref={(el) => { inputRef = el; }}
value={prompt()}
placeholder="Ask the local LLM something..."
onInput={(v) => setPrompt(v)}
onKeyDown={handleKeyDown}
resize-y={true}
mono={false}
/>
</div>
{error() && (
<div style={{
marginTop: "8px",
padding: "8px 12px",
background: "var(--info-danger-background)",
color: "var(--text-danger)",
borderRadius: "4px",
fontSize: "14px",
}}>
{error()}
</div>
)}
</ModalBody>
<ModalFooter>
<div style={{ display: "flex", gap: "8px", justifyContent: "flex-end", width: "100%" }}>
<Button
look={ButtonLooks.FILLED}
color={ButtonColors.PRIMARY}
onClick={handleSubmit}
disabled={loading() || !prompt().trim()}
style={{ minWidth: "180px" }}
>
{loading() ? "Generating..." : "Generate & Insert"}
</Button>
</div>
</ModalFooter>
</ModalRoot>
);
}
// ── Insert the button into the chat bar ─────────────────────────
function injectButton() {
// Look for the button row in the chat bar
const buttonRow = document.querySelector(
'[class*="channelTextArea"] [class*="buttons"], ' +
'[class*="chat"] [class*="channelTextArea"] [class*="buttons"]'
);
if (!buttonRow || buttonRow.querySelector("[data-unsloth-chat-btn]")) return;
const btn = document.createElement("button");
btn.setAttribute("data-unsloth-chat-btn", "");
btn.setAttribute("aria-label", "Unsloth Studio - Generate with AI");
btn.setAttribute("tabindex", "0");
btn.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2a10 10 0 0 1 10 10c0 5-4 8-10 8S2 17 2 12 7 2 12 2z"/>
<path d="M8 12h8"/>
<path d="M12 8v8"/>
</svg>
`;
btn.addEventListener("click", () => {
openModal((p) => <PromptModal close={p.close} />);
});
buttonRow.appendChild(btn);
}
// ── DOM observer ────────────────────────────────────────────────
scoped.observeDom(
'[class*="channelTextArea"] [class*="buttons"]',
injectButton,
);
// ── 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 (
<div style={{ padding: "16px 0" }}>
<Header tag={HeaderTags.HeadingLG}>Unsloth Studio Connection</Header>
<Divider mt mb />
<div style={{ marginBottom: "16px" }}>
<Text tag={TextTags.textSM} style={{ marginBottom: "4px", display: "block" }}>
API URL
</Text>
<TextBox
value={apiUrl()}
placeholder="http://localhost:8000"
onInput={saveApiUrl}
/>
<Text tag={TextTags.textXS} style={{ marginTop: "4px", color: "var(--text-muted)", display: "block" }}>
The URL of your running Unsloth Studio instance.
</Text>
</div>
<div style={{ marginBottom: "16px" }}>
<Text tag={TextTags.textSM} style={{ marginBottom: "4px", display: "block" }}>
API Key (optional)
</Text>
<TextBox
value={apiKey()}
placeholder="Leave blank if no auth required"
onInput={saveApiKey}
/>
<Text tag={TextTags.textXS} style={{ marginTop: "4px", color: "var(--text-muted)", display: "block" }}>
Your Unsloth Studio API key/token for authenticated access.
</Text>
</div>
<div style={{ marginBottom: "16px" }}>
<Text tag={TextTags.textSM} style={{ marginBottom: "4px", display: "block" }}>
Model
</Text>
<TextBox
value={model()}
placeholder="default"
onInput={saveModel}
/>
</div>
<div style={{ marginBottom: "16px" }}>
<Text tag={TextTags.textSM} style={{ marginBottom: "4px", display: "block" }}>
Max Tokens
</Text>
<TextBox
value={maxTokens()}
placeholder="1024"
onInput={saveMaxTokens}
/>
</div>
<div style={{ marginBottom: "16px" }}>
<Text tag={TextTags.textSM} style={{ marginBottom: "4px", display: "block" }}>
Temperature (0.0 - 2.0)
</Text>
<TextBox
value={temperature()}
placeholder="0.7"
onInput={saveTemperature}
/>
</div>
</div>
);
}