🎉 | Project initialized
This commit is contained in:
382
plugins/unsloth-chat/index.jsx
Normal file
382
plugins/unsloth-chat/index.jsx
Normal file
@@ -0,0 +1,382 @@
|
||||
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,
|
||||
};
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let detail = `API returned ${res.status}`;
|
||||
try {
|
||||
const err = await res.json();
|
||||
detail = err.error?.message || err.detail || detail;
|
||||
} catch {}
|
||||
throw new Error(detail);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const text = data?.choices?.[0]?.message?.content || "";
|
||||
return text;
|
||||
}
|
||||
|
||||
// ── 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) {
|
||||
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()}
|
||||
>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
6
plugins/unsloth-chat/plugin.json
Normal file
6
plugins/unsloth-chat/plugin.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Unsloth Chat",
|
||||
"author": "You",
|
||||
"description": "Adds a button to the chatbar that lets you fill text from a local LLM via Unsloth Studio API.",
|
||||
"hash": ""
|
||||
}
|
||||
Reference in New Issue
Block a user