diff --git a/plugins/unsloth-chat/index.jsx b/plugins/unsloth-chat/index.jsx index 945d8a4..1437a82 100644 --- a/plugins/unsloth-chat/index.jsx +++ b/plugins/unsloth-chat/index.jsx @@ -20,6 +20,7 @@ const { ToastColors, injectCss, }, + flux: { stores }, } = shelter; const UNSLOTH_DEFAULT_URL = "http://localhost:8000"; @@ -32,39 +33,38 @@ store.model ??= UNSLOTH_MODEL; store.maxTokens ??= 1024; store.temperature ??= 0.7; -// ── Injected CSS for the button ───────────────────────────────── +// ── Injected CSS for the menu items ───────────────────────────── const removeCss = injectCss(` - [data-unsloth-chat-btn] { + [data-unsloth-menu-item] { 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; + gap: 8px !important; + padding: 6px 10px !important; + margin: 2px 6px !important; cursor: pointer !important; - font-size: 16px !important; - font-weight: 700 !important; + border-radius: 3px !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; + font-size: 14px !important; + line-height: 18px !important; + font-weight: 500 !important; } - [data-unsloth-chat-btn]:hover { - color: var(--interactive-hover) !important; + [data-unsloth-menu-item]:hover { background: var(--background-modifier-hover) !important; + color: var(--interactive-hover) !important; } - [data-unsloth-chat-btn] svg { - width: 20px !important; - height: 20px !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) @@ -431,7 +431,7 @@ function ErrorDetailModal(props) { // ── The prompt modal ──────────────────────────────────────────── function PromptModal(props) { - const [prompt, setPrompt] = shelter.solid.createSignal(""); + const [prompt, setPrompt] = shelter.solid.createSignal(props.initialPrompt || ""); const [loading, setLoading] = shelter.solid.createSignal(false); const [error, setError] = shelter.solid.createSignal(""); @@ -544,38 +544,164 @@ function PromptModal(props) { ); } -// ── 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 = ` - - - - - - `; - btn.addEventListener("click", () => { - openModal((p) => ); - }); - - buttonRow.appendChild(btn); +// ── 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; +} + +// ── Inject items into the + menu popout ───────────────────────── +function injectPlusMenuItems() { + // Look for open menu scrollers — the + popout is a menu layer + const scrollers = document.querySelectorAll('[class*="menu"] [class*="scroller"]'); + for (const scroller of scrollers) { + // Already injected? + if (scroller.querySelector("[data-unsloth-menu-item]")) continue; + + // Identify the + menu: it contains items for upload / create thread etc. + // We look for any element that hints this is the channel-attach popout. + const hint = + scroller.querySelector('[class*="upload"], [class*="attach"]') || + scroller.closest('[class*="channelTextArea"]'); + if (!hint) continue; + + // Append a divider then our two items + 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); + } +} + +// ── Hook the + button to inject items when popout opens ────────── +function hookAttachButton(btn) { + if (btn.dataset.unslothHooked) return; + btn.dataset.unslothHooked = "1"; + btn.addEventListener("click", () => { + // The popout renders asynchronously, so wait a tick + setTimeout(injectPlusMenuItems, 50); + }); } -// ── DOM observer ──────────────────────────────────────────────── scoped.observeDom( - '[class*="channelTextArea"] [class*="buttons"]', - injectButton, + '[class*="channelTextArea"] [class*="attachButton"]', + hookAttachButton, ); // ── Export settings component ───────────────────────────────────