| Move to + menu, add Suggest reply from recent messages

This commit is contained in:
2026-05-19 03:07:01 +03:00
parent 9eb6b55d9e
commit 4342cfce5d

View File

@@ -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 = `<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 3l1.5 5.5L19 10l-5.5 1.5L12 17l-1.5-5.5L5 10l5.5-1.5z"/><path d="M6 14l.5 2L8 16.5 6.5 16 6 18l-.5-2L4 16.5 5.5 16z"/><path d="M16 16l.5 2 1.5-.5-1.5-.5.5-2-.5 2-1.5.5z"/></svg>`;
const REPLY_ICON = `<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"><polyline points="9 17 4 12 9 7"/><path d="M4 12h14a3 3 0 0 1 3 3v4"/></svg>`;
// ── 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 = `
<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);
// ── 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) => <ErrorDetailModal close={p.close} error={e} />);
} 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 + `<span>${label}</span>`;
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) => <PromptModal close={p.close} />);
});
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 ───────────────────────────────────