diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..486ad34 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +.pnpm-debug.log +.idea diff --git a/lune.config.js b/lune.config.js new file mode 100644 index 0000000..105b199 --- /dev/null +++ b/lune.config.js @@ -0,0 +1,9 @@ +// Welcome to your Lune config file! +// You can view documentation on Lune here: +// https://github.com/uwu/shelter/tree/main/packages/lune + +import { defineConfig } from "@uwu/lune"; + +export default defineConfig({ + // configure lune here +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..8895afe --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "devDependencies": { + "@uwu/lune": "^1.6.2", + "@uwu/shelter-defs": "^1.4.1" + }, + "type": "module", + "workspaces": [ + "plugins/*" + ] +} diff --git a/plugins/unsloth-chat/index.jsx b/plugins/unsloth-chat/index.jsx new file mode 100644 index 0000000..48ddd39 --- /dev/null +++ b/plugins/unsloth-chat/index.jsx @@ -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 ( + + Unsloth Studio + +
+ Prompt +
+
+