806 lines
26 KiB
JavaScript
806 lines
26 KiB
JavaScript
const {
|
|
plugin: { store, scoped },
|
|
ui: {
|
|
openModal,
|
|
Button,
|
|
ButtonColors,
|
|
ButtonLooks,
|
|
TextBox,
|
|
TextArea,
|
|
ModalRoot,
|
|
ModalHeader,
|
|
ModalBody,
|
|
ModalFooter,
|
|
Header,
|
|
HeaderTags,
|
|
Text,
|
|
TextTags,
|
|
Divider,
|
|
showToast,
|
|
ToastColors,
|
|
injectCss,
|
|
},
|
|
flux: { stores },
|
|
} = 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 menu items ─────────────────────────────
|
|
const removeCss = injectCss(`
|
|
[data-unsloth-menu-item] {
|
|
display: flex !important;
|
|
align-items: center !important;
|
|
gap: 8px !important;
|
|
padding: 6px 10px !important;
|
|
margin: 2px 6px !important;
|
|
cursor: pointer !important;
|
|
border-radius: 3px !important;
|
|
color: var(--interactive-normal) !important;
|
|
font-size: 14px !important;
|
|
line-height: 18px !important;
|
|
font-weight: 500 !important;
|
|
}
|
|
[data-unsloth-menu-item]:hover {
|
|
background: var(--background-modifier-hover) !important;
|
|
color: var(--interactive-hover) !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)
|
|
const selectors = [
|
|
// Modern Discord: Slate-based contenteditable div
|
|
'[class*="channelTextArea"] div[role="textbox"][contenteditable="true"]',
|
|
'[class*="channelTextArea"] [data-slate-editor="true"]',
|
|
// Classic Discord: textarea inside channelTextArea
|
|
'[class*="channelTextArea"] textarea',
|
|
// Fallback: role="textbox" anywhere inside channelTextArea
|
|
'[class*="channelTextArea"] [role="textbox"]',
|
|
// Even broader fallbacks (no main/chat prefix)
|
|
'main [class*="channelTextArea"] textarea',
|
|
'main [class*="channelTextArea"] [role="textbox"]',
|
|
'[class*="chat"] [class*="channelTextArea"] textarea',
|
|
'[class*="chat"] [class*="channelTextArea"] [role="textbox"]',
|
|
];
|
|
|
|
let editorEl = null;
|
|
for (const sel of selectors) {
|
|
editorEl = document.querySelector(sel);
|
|
if (editorEl) break;
|
|
}
|
|
|
|
if (!editorEl) return false;
|
|
|
|
// Determine the native input element
|
|
let nativeInput;
|
|
const isContentEditable =
|
|
editorEl.tagName !== "TEXTAREA" &&
|
|
(editorEl.isContentEditable || editorEl.getAttribute("contenteditable") === "true");
|
|
|
|
if (editorEl.tagName === "TEXTAREA") {
|
|
// Classic Discord: direct textarea element
|
|
nativeInput = editorEl;
|
|
} else if (isContentEditable) {
|
|
// Modern Discord: Slate/contenteditable div - use it directly
|
|
nativeInput = editorEl;
|
|
} else {
|
|
// Look for a nested textarea (older layout)
|
|
nativeInput = editorEl.querySelector("textarea");
|
|
}
|
|
|
|
if (!nativeInput) return false;
|
|
|
|
// ── Set the value ───────────────────────────────────────────
|
|
nativeInput.focus();
|
|
|
|
if (nativeInput.tagName === "TEXTAREA") {
|
|
// Classic textarea input
|
|
nativeInput.value = text;
|
|
nativeInput.dispatchEvent(new Event("input", { bubbles: true, cancelable: true }));
|
|
} else {
|
|
// ContentEditable div — modern Discord Slate editor
|
|
// Slate listens for "beforeinput" events, not plain DOM mutations.
|
|
// We select all existing content, then dispatch beforeinput so
|
|
// Slate handles the replacement through its own React pipeline.
|
|
|
|
// 1. Select all existing content so the insert replaces everything
|
|
const sel = window.getSelection();
|
|
if (sel) {
|
|
try {
|
|
const range = document.createRange();
|
|
range.selectNodeContents(nativeInput);
|
|
sel.removeAllRanges();
|
|
sel.addRange(range);
|
|
} catch (_) { /* selection API may fail on some elements */ }
|
|
}
|
|
|
|
// 2. Dispatch beforeinput — this is what Slate's withReact
|
|
// plugin hooks into to update the editor's React state.
|
|
// dispatchEvent returns FALSE when the event was cancelled
|
|
// (Slate calls preventDefault when it handles the event).
|
|
// TRUE means nobody cancelled -> we need the fallback.
|
|
let needsFallback = true;
|
|
try {
|
|
const notCancelled = nativeInput.dispatchEvent(new InputEvent("beforeinput", {
|
|
inputType: "insertText",
|
|
data: text,
|
|
bubbles: true,
|
|
cancelable: true,
|
|
}));
|
|
needsFallback = notCancelled;
|
|
} catch (_) {
|
|
// InputEvent constructor may not be available (old Electron)
|
|
}
|
|
|
|
// 3. Fallback: if beforeinput wasn't cancelled by any handler
|
|
// use execCommand which works on older Discord layouts.
|
|
if (needsFallback) {
|
|
try {
|
|
document.execCommand("selectAll", false, null);
|
|
document.execCommand("insertText", false, text);
|
|
} catch (_) {
|
|
// Last resort: set textContent directly
|
|
nativeInput.textContent = text;
|
|
}
|
|
}
|
|
|
|
// 4. Always dispatch input — React-controlled components often
|
|
// listen for this in addition to beforeinput.
|
|
nativeInput.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,
|
|
};
|
|
|
|
let res;
|
|
let fetchError = null;
|
|
|
|
try {
|
|
res = await fetch(url, {
|
|
method: "POST",
|
|
headers,
|
|
body: JSON.stringify(body),
|
|
});
|
|
} catch (e) {
|
|
fetchError = e;
|
|
}
|
|
|
|
if (fetchError) {
|
|
// Network-level error (e.g. Failed to fetch, CORS, DNS, etc.)
|
|
throw new DetailedApiError({
|
|
message: fetchError.message || "Failed to fetch",
|
|
url,
|
|
prompt,
|
|
model: store.model,
|
|
apiUrl: store.apiUrl,
|
|
status: null,
|
|
responseBody: null,
|
|
timestamp: new Date().toISOString(),
|
|
cause: fetchError,
|
|
});
|
|
}
|
|
|
|
if (!res.ok) {
|
|
let responseBody = null;
|
|
let detail = `API returned ${res.status}`;
|
|
try {
|
|
responseBody = await res.text();
|
|
try {
|
|
const err = JSON.parse(responseBody);
|
|
detail = err.error?.message || err.detail || detail;
|
|
} catch {}
|
|
} catch {}
|
|
|
|
throw new DetailedApiError({
|
|
message: detail,
|
|
url,
|
|
prompt,
|
|
model: store.model,
|
|
apiUrl: store.apiUrl,
|
|
status: res.status,
|
|
responseBody,
|
|
timestamp: new Date().toISOString(),
|
|
cause: null,
|
|
});
|
|
}
|
|
|
|
const data = await res.json();
|
|
const text = data?.choices?.[0]?.message?.content || "";
|
|
return text;
|
|
}
|
|
|
|
// ── Detailed error class ─────────────────────────────────────────
|
|
class DetailedApiError extends Error {
|
|
constructor(details) {
|
|
super(details.message);
|
|
this.name = "DetailedApiError";
|
|
this.url = details.url;
|
|
this.prompt = details.prompt;
|
|
this.model = details.model;
|
|
this.apiUrl = details.apiUrl;
|
|
this.status = details.status;
|
|
this.responseBody = details.responseBody;
|
|
this.timestamp = details.timestamp;
|
|
this.cause = details.cause;
|
|
}
|
|
}
|
|
|
|
// ── Error detail modal ──────────────────────────────────────────
|
|
const ERROR_MODAL_CLASS = "uns-errmodal";
|
|
|
|
// Injected CSS for the error modal — wider modal so all detail fits
|
|
const removeErrCss = injectCss(`
|
|
.${ERROR_MODAL_CLASS} {
|
|
width: 620px !important;
|
|
max-width: 90vw !important;
|
|
}
|
|
`);
|
|
scoped.onDispose(() => removeErrCss());
|
|
|
|
function ErrorDetailModal(props) {
|
|
const error = props.error;
|
|
|
|
return (
|
|
<ModalRoot class={ERROR_MODAL_CLASS}>
|
|
<ModalHeader close={props.close}>Unsloth Chat - Error Details</ModalHeader>
|
|
<ModalBody>
|
|
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
|
|
{/* Error message */}
|
|
<div>
|
|
<Header tag={HeaderTags.HeadingSM} margin={false}>Error</Header>
|
|
<div style={{
|
|
marginTop: "4px",
|
|
padding: "8px 12px",
|
|
background: "var(--info-danger-background)",
|
|
color: "var(--text-danger)",
|
|
borderRadius: "4px",
|
|
fontSize: "14px",
|
|
wordBreak: "break-word",
|
|
}}>
|
|
{error.message}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Status code */}
|
|
<div>
|
|
<Header tag={HeaderTags.HeadingSM} margin={false}>HTTP Status</Header>
|
|
<Text tag={TextTags.textSM} style={{ marginTop: "4px", display: "block" }}>
|
|
{error.status !== null ? error.status : "N/A (network error)"}
|
|
</Text>
|
|
</div>
|
|
|
|
{/* API URL used */}
|
|
<div>
|
|
<Header tag={HeaderTags.HeadingSM} margin={false}>Request URL</Header>
|
|
<div style={{
|
|
marginTop: "4px",
|
|
padding: "6px 10px",
|
|
background: "var(--background-secondary)",
|
|
borderRadius: "4px",
|
|
fontFamily: "var(--font-code, monospace)",
|
|
fontSize: "12px",
|
|
wordBreak: "break-all",
|
|
whiteSpace: "pre-wrap",
|
|
}}>
|
|
{error.url}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Configured API URL */}
|
|
<div>
|
|
<Header tag={HeaderTags.HeadingSM} margin={false}>Configured API URL</Header>
|
|
<div style={{
|
|
marginTop: "4px",
|
|
padding: "6px 10px",
|
|
background: "var(--background-secondary)",
|
|
borderRadius: "4px",
|
|
fontFamily: "var(--font-code, monospace)",
|
|
fontSize: "12px",
|
|
wordBreak: "break-all",
|
|
whiteSpace: "pre-wrap",
|
|
}}>
|
|
{error.apiUrl}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Model */}
|
|
<div>
|
|
<Header tag={HeaderTags.HeadingSM} margin={false}>Model</Header>
|
|
<Text tag={TextTags.textSM} style={{ marginTop: "4px", display: "block" }}>
|
|
{error.model}
|
|
</Text>
|
|
</div>
|
|
|
|
{/* Prompt */}
|
|
<div>
|
|
<Header tag={HeaderTags.HeadingSM} margin={false}>Prompt</Header>
|
|
<div style={{
|
|
marginTop: "4px",
|
|
padding: "8px 12px",
|
|
background: "var(--background-secondary)",
|
|
borderRadius: "4px",
|
|
fontSize: "13px",
|
|
whiteSpace: "pre-wrap",
|
|
wordBreak: "break-word",
|
|
maxHeight: "120px",
|
|
overflowY: "auto",
|
|
}}>
|
|
{error.prompt}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Response body */}
|
|
{error.responseBody && (
|
|
<div>
|
|
<Header tag={HeaderTags.HeadingSM} margin={false}>Response Body</Header>
|
|
<div style={{
|
|
marginTop: "4px",
|
|
padding: "8px 12px",
|
|
background: "var(--background-secondary)",
|
|
borderRadius: "4px",
|
|
fontSize: "12px",
|
|
fontFamily: "var(--font-code, monospace)",
|
|
whiteSpace: "pre-wrap",
|
|
wordBreak: "break-word",
|
|
maxHeight: "200px",
|
|
overflowY: "auto",
|
|
}}>
|
|
{error.responseBody}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Cause (network error details) */}
|
|
{error.cause && (
|
|
<div>
|
|
<Header tag={HeaderTags.HeadingSM} margin={false}>Network Error</Header>
|
|
<div style={{
|
|
marginTop: "4px",
|
|
padding: "8px 12px",
|
|
background: "var(--background-secondary)",
|
|
borderRadius: "4px",
|
|
fontSize: "12px",
|
|
fontFamily: "var(--font-code, monospace)",
|
|
whiteSpace: "pre-wrap",
|
|
wordBreak: "break-word",
|
|
}}>
|
|
{error.cause.toString()}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Timestamp */}
|
|
<div>
|
|
<Text tag={TextTags.textXS} style={{ color: "var(--text-muted)", display: "block" }}>
|
|
Occurred at: {error.timestamp}
|
|
</Text>
|
|
</div>
|
|
</div>
|
|
</ModalBody>
|
|
<ModalFooter>
|
|
<div style={{ display: "flex", gap: "8px", justifyContent: "flex-end", width: "100%" }}>
|
|
<Button
|
|
look={ButtonLooks.FILLED}
|
|
color={ButtonColors.PRIMARY}
|
|
onClick={props.close}
|
|
>
|
|
Close
|
|
</Button>
|
|
</div>
|
|
</ModalFooter>
|
|
</ModalRoot>
|
|
);
|
|
}
|
|
|
|
// ── The prompt modal ────────────────────────────────────────────
|
|
function PromptModal(props) {
|
|
const [prompt, setPrompt] = shelter.solid.createSignal(props.initialPrompt || "");
|
|
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) {
|
|
if (e instanceof DetailedApiError) {
|
|
openModal((p) => <ErrorDetailModal close={p.close} error={e} />);
|
|
} else {
|
|
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()}
|
|
style={{ minWidth: "180px" }}
|
|
>
|
|
{loading() ? "Generating..." : "Generate & Insert"}
|
|
</Button>
|
|
</div>
|
|
</ModalFooter>
|
|
</ModalRoot>
|
|
);
|
|
}
|
|
|
|
// ── 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);
|
|
});
|
|
}
|
|
|
|
scoped.observeDom(
|
|
'[class*="channelTextArea"] [class*="attachButton"]',
|
|
hookAttachButton,
|
|
);
|
|
|
|
// ── 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>
|
|
);
|
|
}
|