✨ | Move to + menu, add Suggest reply from recent messages
This commit is contained in:
@@ -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 ───────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user