// In-engine agent — "Forge" client (EventSource transport for UE CEF). (() => { "use strict"; const $ = (id) => document.getElementById(id); const I = { brand: '', history: '', plus: '', search: '', server: '', mention: '', tools: '', clip: '', send: '', check: '', x: '', chev: '', asset: '', chat: '', spark: '', trash: '', gear: '', volume: '', download: '', play: '', pause: '', textfix: '', translate: '', }; // ---- state ------------------------------------------------------------- let sessionId = null, busy = false, turn = null; let model = localStorage.getItem("mcp_model") || "claude-opus-4-8"; let MODELS = [{ id: model, name: model, tag: "" }]; let pending = []; // pending attachments [{dataUrl, media, name}] let chats = []; // [{id,title,model,sessionId,at,msgs:[...]}] let activeId = null; const inputEl = $("input"), sendEl = $("send"), threadEl = $("thread"), statusDot = $("statusDot"), statusText = $("statusText"), toastEl = $("toast"), threadWrap = $("threadWrap"), mentionPop = $("mentionPop"), modelPop = $("modelPop"); function mountIcons() { $("brandMark").innerHTML = I.brand; $("historyBtn").innerHTML = I.history; $("newChatIcon").innerHTML = I.plus; $("searchIcon").innerHTML = I.search; $("mcpIcon").innerHTML = I.server; $("mentionIcon").innerHTML = I.mention; $("toolsIcon").innerHTML = I.tools; $("attachIcon").innerHTML = I.clip; $("sendIcon").innerHTML = I.send; $("modelGlyph").innerHTML = I.spark; $("modelChev").innerHTML = I.chev; $("soundIcon").innerHTML = I.volume; $("userChev").innerHTML = I.chev; $("settingsIcon").innerHTML = I.gear; $("integrationsIcon").innerHTML = I.tools; $("textFixIcon").innerHTML = I.textfix; $("translateIcon").innerHTML = I.translate; } // ---- health ------------------------------------------------------------ function refreshHealth() { return fetch("/api/health").then((r) => r.json()).then((h) => { if (Array.isArray(h.models) && h.models.length) MODELS = h.models; if (!MODELS.find((m) => m.id === model)) model = h.model || MODELS[0].id; const mm = MODELS.find((m) => m.id === model); $("modelName").textContent = mm ? mm.name : model; if (h.ueToolCount) ueToolCount = h.ueToolCount; setMcp(h.tools || []); if (h.hasAuth === false) setStatus("warn", "нет токена"); else setStatus("on", "online"); }).catch(() => setStatus("off", "offline")); } function setStatus(kind, text) { statusDot.className = "dot " + (kind === "on" ? "on" : kind === "warn" ? "warn" : "off"); statusText.textContent = text; } let ueToolCount = 0; function setMcp(tools) { const ue = (tools || []).filter((t) => t.startsWith("mcp__ue-blueprint__")).length; if (ue) ueToolCount = ue; $("toolsCount").textContent = ueToolCount || (tools || []).length || ""; $("mcpCount").textContent = "1/1"; $("mcpList").innerHTML = `
${esc(parts[i].replace(/^[a-zA-Z0-9]*\n/, ""))}`; }
else {
let seg = esc(parts[i]);
seg = seg.replace(/`([^`]+)`/g, (_, c) => `${c}`);
seg = seg.replace(/\*\*([^*]+)\*\*/g, "$1");
seg = seg.split(/(.*?<\/code>)/g).map((s) => (s.startsWith("") ? s : chipsInSegment(s))).join("");
html += seg.replace(/\n/g, "
");
}
}
return html;
}
function nowStr() { const d = new Date(); return d.getHours() + ":" + String(d.getMinutes()).padStart(2, "0"); }
// throttled scroll
let scrollQueued = false;
function scrollDown() { if (scrollQueued) return; scrollQueued = true; requestAnimationFrame(() => { scrollQueued = false; threadWrap.scrollTop = threadWrap.scrollHeight; }); }
// ---- message builders --------------------------------------------------
function imagesHtml(images) {
if (!images || !images.length) return "";
return `${images.map((d) => `
`).join("")}`;
}
function addUserMessage(text, images) {
const el = document.createElement("div"); el.className = "msg user-msg";
el.innerHTML = `EXВы ${nowStr()}` +
imagesHtml(images) + `${renderMarkdown(text)}`;
threadEl.appendChild(el); scrollDown();
}
function addAgentShell() {
const el = document.createElement("div"); el.className = "msg";
el.innerHTML = `${I.brand}Forge ${($("modelName").textContent)}`;
threadEl.appendChild(el);
return { el, body: el.querySelector(".msg-body") };
}
function audioSetHtml(message) {
const sounds = message.sounds || [];
return `ElevenLabs variations for: ${esc(message.prompt || "")}` +
sounds.map((s, i) => `${I.volume}${esc(s.name || `variation_${i + 1}`)}0:000:00`).join("") +
``;
}
function renderStoredAudio(message) {
const sh = addAgentShell();
const el = document.createElement("div"); el.className = "generated-audio"; el.innerHTML = audioSetHtml(message);
sh.body.appendChild(el);
}
function addAudioMessage(prompt, sounds) {
const message = { role: "audio", prompt, sounds };
renderStoredAudio(message);
pushMsg(message); saveChats(); scrollDown();
}
function fmtTime(seconds) {
if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
const mins = Math.floor(seconds / 60), secs = Math.floor(seconds % 60);
return `${mins}:${String(secs).padStart(2, "0")}`;
}
function syncPlayer(card) {
const audio = card.querySelector("[data-player-audio]");
const progress = card.querySelector("[data-player-progress]");
card.querySelector("[data-player-current]").textContent = fmtTime(audio.currentTime);
card.querySelector("[data-player-duration]").textContent = fmtTime(audio.duration);
progress.value = audio.duration ? String(Math.round((audio.currentTime / audio.duration) * 1000)) : "0";
const play = card.querySelector("[data-player-play]");
play.innerHTML = audio.paused ? I.play : I.pause;
play.title = audio.paused ? "Play" : "Pause";
}
function stopOtherPlayers(except) {
threadEl.querySelectorAll("[data-player-audio]").forEach((audio) => {
if (audio !== except && !audio.paused) { audio.pause(); syncPlayer(audio.closest("[data-audio-card]")); }
});
}
const VERB = {
ping_ue: "Пинг UE", read_graph: "Чтение графа", apply_graph: "Правка графа", apply_batch: "Правка графа",
compile: "Компиляция", introspect: "Интроспекция", discover_op: "Поиск в проекте", asset_op: "Ассет",
widget_op: "Виджет", level_op: "Уровень", material_op: "Материал", niagara_op: "Niagara", console_op: "Консоль",
live_coding_op: "Live Coding", build_op: "Сборка", validation_op: "Валидация", read_python_log: "Лог Python",
get_active_blueprint: "Активный BP", selection_op: "Выделение", voxel_graph_op: "Voxel", animation_op: "Анимация",
audio_op: "Аудио", ai_op: "AI", input_op: "Input", mesh_op: "Меш", render_op: "Рендер", network_op: "Сеть",
preset_op: "Пресет", cull_op: "Culling", Read: "Чтение", Edit: "Правка", Write: "Запись", Bash: "Bash",
Grep: "Поиск", Glob: "Поиск файлов", TodoWrite: "План", Task: "Подзадача", WebFetch: "Веб", WebSearch: "Веб-поиск",
};
const toolBase = (n) => n.replace(/^mcp__ue-blueprint__/, "").replace(/^mcp__[^_]+__/, "");
function toolLabel(n) { if (n.startsWith("mcp__ue-blueprint__")) return "ue." + toolBase(n); if (n.startsWith("mcp__")) return n.replace(/^mcp__([^_]+)__/, "$1."); return n; }
function targetOf(input) {
if (!input || typeof input !== "object") return null;
let t = input.bp_path || input.path || input.widget || input.class || input.bp || input.file_path || input.class_path;
if (!t && Array.isArray(input.ops) && input.ops[0]) t = input.ops[0].bp_path;
return typeof t === "string" ? t : null;
}
function chipFor(tgt, base) {
if (!tgt) return "";
if (tgt.startsWith("/Game/")) return `${I.asset}${esc(tgt.split("/").pop())}`;
if (/^[A-Za-z0-9_]+$/.test(tgt) && tgt !== base) return `${I.asset}${esc(tgt)}`;
return "";
}
function plural(n) { return n % 10 === 1 && n % 100 !== 11 ? "шаг" : (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20) ? "шага" : "шагов"); }
function ensureConsole() {
if (turn.console) return;
const c = document.createElement("div"); c.className = "console";
c.innerHTML = `Работаю… ${I.chev}`;
turn.body.appendChild(c); turn.console = c; turn.stepsEl = c.querySelector(".console-inner");
c.querySelector(".console-head").addEventListener("click", () => c.classList.toggle("collapsed"));
scrollDown();
}
function addStep(id, name, input) {
ensureConsole(); turn.nStep++;
const base = toolBase(name), verb = VERB[base] || VERB[name] || "Инструмент", tgt = targetOf(input);
const step = document.createElement("div"); step.className = "step run";
step.innerHTML = `${turn.nStep}${esc(verb)}${chipFor(tgt, base)}${esc(toolLabel(name))}`;
turn.stepsEl.appendChild(step);
turn.steps.set(id, { el: step, t0: performance.now(), verb, tgt, tool: toolLabel(name) });
if (turn.console) turn.console.querySelector(".console-meta").textContent = "· " + turn.nStep + " " + plural(turn.nStep);
scrollDown();
}
function finishStep(id, isError, content) {
const s = turn && turn.steps.get(id); if (!s) return;
const dur = ((performance.now() - s.t0) / 1000).toFixed(1) + "s";
s.el.classList.remove("run"); s.el.classList.add(isError ? "err" : "done");
s.el.querySelector(".step-node").innerHTML = isError ? I.x : I.check;
s.el.querySelector(".step-dur").textContent = dur;
s.dur = dur; s.ok = !isError;
const text = (content || "").toString().trim();
if (text) {
const toggle = s.el.querySelector(".step-toggle"), detail = s.el.querySelector(".tool-detail"), wrap = s.el.querySelector(".collapsible");
detail.style.display = ""; detail.innerHTML = `результат${esc(text.length > 6000 ? text.slice(0, 6000) + "\n…" : text)}`;
toggle.style.display = "";
toggle.addEventListener("click", () => { wrap.classList.toggle("open"); toggle.textContent = wrap.classList.contains("open") ? "hide" : "details"; });
if (isError) { wrap.classList.add("open"); toggle.textContent = "hide"; }
}
scrollDown();
}
function ensureText() {
if (turn.textEl) return;
turn.textEl = document.createElement("div"); turn.textEl.className = "msg-text";
turn.liveEl = document.createElement("span"); turn.caretEl = document.createElement("span"); turn.caretEl.className = "streaming-caret";
turn.textEl.appendChild(turn.liveEl); turn.textEl.appendChild(turn.caretEl);
turn.body.appendChild(turn.textEl);
}
function appendText(delta) { ensureText(); turn.raw += delta; turn.liveEl.appendChild(document.createTextNode(delta)); scrollDown(); }
function appendThinking(delta) { if (!turn.thinkEl) { turn.thinkEl = document.createElement("div"); turn.thinkEl.className = "think"; turn.body.insertBefore(turn.thinkEl, turn.console || null); } turn.thinkEl.textContent += delta; scrollDown(); }
function finalizeTurn(durMs) {
if (!turn) return;
if (turn.console) {
turn.console.classList.add("collapsed");
const head = turn.console.querySelector(".console-head");
const spin = head.querySelector(".console-spin"); if (spin) spin.outerHTML = `${I.check}`;
const secs = durMs ? (durMs / 1000).toFixed(0) : Math.round((performance.now() - turn.t0) / 1000);
head.querySelector(".console-title").childNodes[0].nodeValue = `Готово за ${secs}s `;
}
if (turn.textEl) {
if (turn.caretEl) turn.caretEl.remove();
if (turn.raw.trim()) turn.textEl.innerHTML = renderMarkdown(turn.raw); else turn.textEl.remove();
}
if (!turn.console && !turn.textEl) turn.el.remove();
// persist assistant message
const steps = [];
for (const [, s] of turn.steps) steps.push({ verb: s.verb, tgt: s.tgt, tool: s.tool, dur: s.dur, ok: s.ok });
pushMsg({ role: "assistant", text: turn.raw, steps });
turn = null;
saveChats();
}
// ---- static render of a stored assistant message (on chat reload) ------
function renderStoredAssistant(m) {
const sh = addAgentShell();
if (m.steps && m.steps.length) {
const c = document.createElement("div"); c.className = "console collapsed";
const rows = m.steps.map((s, i) => `${s.ok === false ? I.x : I.check}${esc(s.verb || "")}${chipFor(s.tgt, "")}${esc(s.tool || "")}${esc(s.dur || "")}`).join("");
c.innerHTML = `${I.check}Готово ${I.chev}${rows}`;
c.querySelector(".console-head").addEventListener("click", () => c.classList.toggle("collapsed"));
sh.body.appendChild(c);
}
if (m.text && m.text.trim()) { const t = document.createElement("div"); t.className = "msg-text"; t.innerHTML = renderMarkdown(m.text); sh.body.appendChild(t); }
}
// ---- chat persistence --------------------------------------------------
function loadChats() { try { chats = JSON.parse(localStorage.getItem("mcp_chats_v1") || "[]"); } catch { chats = []; } activeId = localStorage.getItem("mcp_active") || null; }
function saveChats() { try { localStorage.setItem("mcp_chats_v1", JSON.stringify(chats.slice(0, 50))); if (activeId) localStorage.setItem("mcp_active", activeId); } catch {} }
function activeChat() { return chats.find((c) => c.id === activeId); }
function pushMsg(m) { const c = activeChat(); if (c) { c.msgs.push(m); c.at = Date.now(); } }
function renderSidebar() {
const groups = { "Сегодня": [], "Ранее": [] };
const today = new Date(); today.setHours(0, 0, 0, 0);
chats.slice().sort((a, b) => b.at - a.at).forEach((c) => { (c.at >= today.getTime() ? groups["Сегодня"] : groups["Ранее"]).push(c); });
let html = "";
for (const g of ["Сегодня", "Ранее"]) {
if (!groups[g].length) continue;
html += `${g}`;
for (const c of groups[g]) html += `${esc(c.title)}${I.trash}`;
}
$("sbScroll").innerHTML = html || `Сессии`;
}
function renderThread() {
threadEl.innerHTML = "";
const c = activeChat();
if (!c || !c.msgs.length) { showEmptyState(); return; }
for (const m of c.msgs) { if (m.role === "user") addUserMessage(m.text, m.images); else if (m.role === "audio") renderStoredAudio(m); else renderStoredAssistant(m); }
scrollDown();
}
function newChat() {
const c = { id: "c" + Date.now().toString(36), title: "Новый чат", model, sessionId: null, at: Date.now(), msgs: [] };
chats.unshift(c); activeId = c.id; sessionId = null; turn = null;
$("convTitle").textContent = "Новый разговор"; $("convSub").textContent = "ue-blueprint MCP";
renderSidebar(); renderThread(); saveChats(); inputEl.focus();
}
function switchChat(id) {
const c = chats.find((x) => x.id === id); if (!c) return;
activeId = id; sessionId = c.sessionId || null; model = c.model || model; turn = null;
const mm = MODELS.find((m) => m.id === model); $("modelName").textContent = mm ? mm.name : model;
$("convTitle").textContent = c.title; updateConvSub(c);
renderSidebar(); renderThread(); saveChats();
}
function deleteChat(id) {
chats = chats.filter((c) => c.id !== id);
if (activeId === id) { activeId = chats[0] ? chats[0].id : null; if (activeId) switchChat(activeId); else newChat(); }
renderSidebar(); saveChats();
}
function updateConvSub(c) {
const n = (c && c.msgs ? c.msgs.reduce((a, m) => a + (m.steps ? m.steps.length : 0), 0) : 0);
$("convSub").textContent = (n ? n + " tool calls · " : "") + "ue-blueprint MCP";
}
// ---- empty state -------------------------------------------------------
function showEmptyState() {
const es = document.createElement("div"); es.className = "empty-state";
es.innerHTML = `Forge — копилот внутри движка
Я работаю с проектом через ue-blueprint MCP и могу править Blueprints, спавнить ноды, билдить C++, делать скриншоты UMG.
${sugg("Пингани UE", "проверь связь и активный Blueprint")}${sugg("Список MCP-инструментов", "что ты умеешь")}${sugg("Опиши активный Blueprint", "разбери граф событий")}${sugg("Открой /Game", "покажи структуру проекта")}`;
threadEl.appendChild(es);
es.querySelectorAll(".suggest").forEach((s) => s.addEventListener("click", () => { inputEl.value = s.dataset.q; autoGrow(); inputEl.focus(); }));
}
function sugg(t, d) { return ``; }
// ---- event handling ----------------------------------------------------
function handleEvent(name, data) {
switch (name) {
case "init": sessionId = data.sessionId || sessionId; { const c = activeChat(); if (c) c.sessionId = sessionId; } if (data.tools) setMcp(data.tools); break;
case "tool_start": addStep(data.id, data.name, data.input); break;
case "tool_end": finishStep(data.id, data.isError, data.content); break;
case "text": appendText(data.delta || ""); break;
case "thinking": appendThinking(data.delta || ""); break;
case "error": { ensureText(); if (turn.caretEl) turn.caretEl.remove(); turn.textEl.insertAdjacentHTML("beforeend", `⚠ ${esc(data.message || "ошибка")}`); setStatus("warn", "ошибка"); break; }
case "done": sessionId = data.sessionId || sessionId; break;
}
}
// ---- send --------------------------------------------------------------
async function send() {
const text = inputEl.value.trim();
if ((!text && !pending.length) || busy) return;
if (!pending.length && text.toLowerCase().startsWith("/sound ")) {
inputEl.value = ""; autoGrow();
await generateSounds(text.slice(7));
return;
}
closeMention();
busy = true; sendEl.disabled = true;
setStatus("on", "думает…");
const imgs = pending.slice(); pending = []; renderAttachRow();
const dataUrls = imgs.map((p) => p.dataUrl);
inputEl.value = ""; autoGrow();
// ensure there is an active chat
if (!activeChat()) newChat();
const chat = activeChat();
if (chat.msgs.length === 0) { chat.title = (text || "Изображение").slice(0, 48); $("convTitle").textContent = chat.title; renderSidebar(); }
const empty = threadEl.querySelector(".empty-state"); if (empty) empty.remove();
addUserMessage(text, dataUrls);
pushMsg({ role: "user", text, images: dataUrls });
saveChats();
turn = Object.assign(addAgentShell(), { console: null, stepsEl: null, steps: new Map(), nStep: 0, thinkEl: null, textEl: null, liveEl: null, caretEl: null, raw: "", t0: performance.now() });
// upload attachments → ids
let attachIds = [];
try {
for (const p of imgs) {
const r = await fetch("/api/upload", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ dataUrl: p.dataUrl }) }).then((x) => x.json());
if (r && r.ok) attachIds.push(r.id);
}
} catch {}
const qs = "message=" + encodeURIComponent(text) + "&model=" + encodeURIComponent(model) +
(sessionId ? "&sessionId=" + encodeURIComponent(sessionId) : "") +
(attachIds.length ? "&attach=" + attachIds.join(",") : "");
const es = new EventSource("/api/chat-stream?" + qs);
let finished = false, durMs = 0;
const finish = () => { if (finished) return; finished = true; try { es.close(); } catch {} finalizeTurn(durMs); const c = activeChat(); if (c) updateConvSub(c); busy = false; sendEl.disabled = false; setStatus("on", "online"); refreshHealth(); inputEl.focus(); };
["init", "text", "thinking", "tool_start", "tool_end"].forEach((nm) => es.addEventListener(nm, (e) => { let d; try { d = JSON.parse(e.data); } catch { return; } handleEvent(nm, d); }));
es.addEventListener("done", (e) => { let d = {}; try { d = JSON.parse(e.data); } catch {} durMs = d.durationMs || 0; handleEvent("done", d); finish(); });
es.addEventListener("error", (e) => { let d = null; try { d = JSON.parse(e.data); } catch {} if (d) handleEvent("error", d); else if (!finished && turn) { ensureText(); if (turn.caretEl) turn.caretEl.remove(); turn.textEl.insertAdjacentHTML("beforeend", `⚠ потеряна связь с гейтвеем`); setStatus("off", "offline"); } finish(); });
}
// ---- integration settings / ElevenLabs -------------------------------
function openModal(id) { $(id).style.display = "grid"; }
function closeModal(id) { $(id).style.display = "none"; }
function setIntegrationState(id, connected, text) {
const el = $(id); el.textContent = text; el.classList.toggle("connected", !!connected);
}
async function loadSettings() {
const settings = await fetch("/api/settings").then((r) => r.json());
const eleven = settings.elevenlabs || {}, codex = settings.codex || {};
$("elevenApiKey").value = "";
$("elevenApiKey").placeholder = eleven.apiKeyMasked || "xi-api-key";
$("elevenKeyHint").textContent = eleven.configured ? `Connected as ${eleven.apiKeyMasked}. Leave blank to keep this key.` : "Enter a key to connect ElevenLabs.";
$("elevenImportPath").value = eleven.importPath || "/Game/Audio/Generated";
$("elevenVariations").value = String(eleven.variationCount || 3);
$("elevenSoundModel").value = eleven.modelId || "eleven_text_to_sound_v2";
$("elevenDefaultDuration").value = eleven.durationSeconds == null ? "" : String(eleven.durationSeconds);
$("elevenDefaultInfluence").value = String(eleven.promptInfluence ?? 0.3);
$("soundVariations").value = String(eleven.variationCount || 3);
$("soundDuration").value = eleven.durationSeconds == null ? "" : String(eleven.durationSeconds);
$("soundInfluence").value = String(Math.round((eleven.promptInfluence ?? 0.3) * 100));
$("soundInfluenceValue").textContent = $("soundInfluence").value + "%";
setSoundLoop(!!eleven.loop);
$("textFixPrompt").value = settings.textTools?.improvePrompt || "";
$("translateLanguages").value = (settings.textTools?.translateLanguages || []).join(", ");
setIntegrationState("elevenState", eleven.configured, eleven.configured ? "Connected" : "Not configured");
setIntegrationState("codexState", codex.connected, codex.connected ? "OAuth connected" : "Not connected");
$("codexLogin").textContent = codex.connected ? "Re-authorize OAuth" : "Authorize with OAuth";
return settings;
}
async function openSettings() {
$("userPop").style.display = "none"; $("userRow").classList.remove("open");
openModal("settingsModal");
try { await loadSettings(); } catch { toast("Could not load settings"); }
}
async function saveSettings() {
const key = $("elevenApiKey").value.trim();
const elevenlabs = {
importPath: $("elevenImportPath").value.trim(),
variationCount: +$("elevenVariations").value,
modelId: $("elevenSoundModel").value,
durationSeconds: $("elevenDefaultDuration").value || null,
promptInfluence: +$("elevenDefaultInfluence").value,
};
const textTools = { improvePrompt: $("textFixPrompt").value.trim(), translateLanguages: $("translateLanguages").value.split(",").map((x) => x.trim()).filter(Boolean) };
if (key) elevenlabs.apiKey = key;
const btn = $("saveSettings"); btn.disabled = true;
try {
const result = await fetch("/api/settings", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ elevenlabs, textTools }) }).then((r) => r.json());
if (!result.ok) throw new Error(result.error || "Could not save settings");
closeModal("settingsModal"); toast("Integration settings saved");
} catch (e) { toast(e.message || "Could not save settings"); }
finally { btn.disabled = false; }
}
async function generateSounds(promptOverride) {
const prompt = (typeof promptOverride === "string" ? promptOverride : $("soundPrompt").value).trim();
if (!prompt) { toast("Describe the sound first"); return; }
const btn = $("generateSound"); btn.disabled = true; btn.textContent = "Generating...";
closeModal("soundModal");
if (!activeChat()) newChat();
const chat = activeChat();
if (!chat.msgs.length) { chat.title = `Sound: ${prompt}`.slice(0, 48); $("convTitle").textContent = chat.title; renderSidebar(); }
const empty = threadEl.querySelector(".empty-state"); if (empty) empty.remove();
addUserMessage(`/sound ${prompt}`);
pushMsg({ role: "user", text: `/sound ${prompt}` }); saveChats();
const shell = addAgentShell(), working = document.createElement("div");
working.className = "sound-working"; working.innerHTML = `Generating ElevenLabs variations...`;
shell.body.appendChild(working); scrollDown();
try {
const result = await fetch("/api/elevenlabs/generate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({
prompt,
loop: $("soundLoop").classList.contains("on"),
durationSeconds: $("soundDuration").value || null,
promptInfluence: +$("soundInfluence").value / 100,
variationCount: +$("soundVariations").value,
modelId: "eleven_text_to_sound_v2",
}) }).then((r) => r.json());
if (!result.ok) throw new Error(result.error || "Sound generation failed");
shell.el.remove();
addAudioMessage(result.prompt, result.sounds);
toast(`${result.sounds.length} sound variations ready`);
} catch (e) {
working.innerHTML = `${esc(e.message || "Sound generation failed")}`;
toast("Sound generation failed");
} finally {
btn.disabled = false; btn.textContent = "Generate variations";
}
}
async function runDraftTool(endpoint, payload, button, workingText) {
const text = inputEl.value.trim();
if (!text) { toast("Write a draft first"); inputEl.focus(); return; }
const original = button.innerHTML; button.disabled = true; button.textContent = workingText;
try {
const result = await fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text, ...payload }) }).then((r) => r.json());
if (!result.ok) throw new Error(result.error || "Text tool failed");
inputEl.value = result.text; autoGrow(); inputEl.focus(); toast("Draft updated");
} catch (e) { toast(e.message || "Text tool failed"); }
finally { button.disabled = false; button.innerHTML = original; }
}
function setSoundLoop(on) {
$("soundLoop").classList.toggle("on", on);
$("soundLoop").setAttribute("aria-pressed", on ? "true" : "false");
$("soundLoop").querySelector("strong").textContent = on ? "On" : "Off";
}
// ---- asset chip clicks -------------------------------------------------
threadEl.addEventListener("click", (e) => {
const playBtn = e.target.closest("[data-player-play]");
if (playBtn) {
const card = playBtn.closest("[data-audio-card]"), audio = card.querySelector("[data-player-audio]");
if (audio.paused) { stopOtherPlayers(audio); audio.play().catch(() => toast("Could not play this sound")); } else audio.pause();
syncPlayer(card);
return;
}
const importBtn = e.target.closest("[data-import-audio]");
if (importBtn) {
if (importBtn.classList.contains("done")) return;
importBtn.disabled = true; importBtn.textContent = "Importing...";
fetch("/api/elevenlabs/import", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: importBtn.getAttribute("data-import-audio") }) })
.then((r) => r.json()).then((result) => {
if (!result.ok) throw new Error(result.error || "Import failed");
importBtn.classList.add("done"); importBtn.textContent = "Imported";
const sound = chats.flatMap((c) => c.msgs || []).filter((m) => m.role === "audio").flatMap((m) => m.sounds || []).find((s) => s.id === importBtn.getAttribute("data-import-audio"));
if (sound) { sound.imported = true; sound.assetPath = result.paths && result.paths[0]; saveChats(); }
toast(result.paths && result.paths[0] ? `Imported: ${result.paths[0]}` : "Sound imported");
}).catch((err) => { importBtn.disabled = false; importBtn.innerHTML = `${I.download} Import`; toast(err.message || "Import failed"); });
return;
}
const chip = e.target.closest("[data-asset]"); if (!chip) return;
const path = chip.getAttribute("data-asset"), copy = chip.getAttribute("data-copy") || path;
if (copy) { try { navigator.clipboard.writeText(copy); } catch {} }
if (path && path.startsWith("/Game/")) {
fetch("/api/focus", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ path }) })
.then((r) => r.json()).then((res) => toast(res.ok ? "→ выделено в Content Browser" : "путь скопирован")).catch(() => toast("путь скопирован"));
} else if (copy) toast("скопировано: " + copy);
});
threadEl.addEventListener("input", (e) => {
const card = e.target.closest("[data-audio-card]"); if (!card) return;
const audio = card.querySelector("[data-player-audio]");
if (e.target.matches("[data-player-progress]") && audio.duration) audio.currentTime = (+e.target.value / 1000) * audio.duration;
if (e.target.matches("[data-player-volume]")) audio.volume = +e.target.value / 100;
syncPlayer(card);
});
["loadedmetadata", "timeupdate", "play", "pause", "ended"].forEach((name) => threadEl.addEventListener(name, (e) => {
if (!e.target.matches?.("[data-player-audio]")) return;
syncPlayer(e.target.closest("[data-audio-card]"));
}, true));
// ---- sidebar clicks ----------------------------------------------------
$("sbScroll").addEventListener("click", (e) => {
const del = e.target.closest("[data-del]"); if (del) { e.stopPropagation(); deleteChat(del.getAttribute("data-del")); return; }
const item = e.target.closest("[data-chat]"); if (item) switchChat(item.getAttribute("data-chat"));
});
// ---- model popover -----------------------------------------------------
function toggleModelPop() {
if (modelPop.style.display === "block") { modelPop.style.display = "none"; return; }
modelPop.innerHTML = `Модель` + MODELS.map((m) =>
`${I.spark}${esc(m.name)}${esc(m.tag || m.id)}${m.id === model ? `${I.check}` : ""}`).join("");
modelPop.style.display = "block";
}
modelPop.addEventListener("click", (e) => {
const it = e.target.closest("[data-model]"); if (!it) return;
if (it.getAttribute("data-disabled")) { toast("Codex OAuth is connected, but Forge needs a Codex runtime transport before it can use this model"); return; }
const id = it.getAttribute("data-model"); modelPop.style.display = "none";
if (id === model) return;
model = id; localStorage.setItem("mcp_model", model);
const mm = MODELS.find((m) => m.id === model); $("modelName").textContent = mm ? mm.name : model;
const c = activeChat();
if (c && c.msgs.length) { toast("Модель: " + (mm ? mm.name : model) + " — новый чат"); newChat(); }
else { if (c) c.model = model; toast("Модель: " + (mm ? mm.name : model)); }
});
document.addEventListener("click", (e) => { if (!e.target.closest("#modelBtn") && !e.target.closest("#modelPop")) modelPop.style.display = "none"; });
// ---- @mention autocomplete --------------------------------------------
let menItems = [], menHl = 0, menTimer = null, menActive = false;
function currentToken() {
const pos = inputEl.selectionStart; const before = inputEl.value.slice(0, pos);
const m = before.match(/@([^\s@]*)$/); return m ? { q: m[1], start: pos - m[0].length, end: pos } : null;
}
function openMention(q) {
menActive = true;
clearTimeout(menTimer);
menTimer = setTimeout(() => {
fetch("/api/assets?q=" + encodeURIComponent(q)).then((r) => r.json()).then((j) => {
if (!menActive) return;
menItems = j.assets || []; menHl = 0; renderMention();
}).catch(() => {});
}, 160);
}
function renderMention() {
if (!menItems.length) { mentionPop.innerHTML = `ничего не найдено`; mentionPop.style.display = "block"; return; }
mentionPop.innerHTML = menItems.map((a, i) =>
`${I.asset}${esc(a.name)} ${a.open ? 'OPEN' : ""}${esc(a.path)}${esc(a.class || "")}`).join("");
mentionPop.style.display = "block";
}
function closeMention() { menActive = false; mentionPop.style.display = "none"; menItems = []; }
function acceptMention(i) {
const a = menItems[i]; if (!a) return;
const tok = currentToken(); if (!tok) { closeMention(); return; }
const v = inputEl.value, ins = "@" + a.path + " ";
inputEl.value = v.slice(0, tok.start) + ins + v.slice(tok.end);
const caret = tok.start + ins.length; inputEl.setSelectionRange(caret, caret);
closeMention(); autoGrow(); inputEl.focus();
}
mentionPop.addEventListener("click", (e) => { const r = e.target.closest("[data-i]"); if (r) acceptMention(+r.getAttribute("data-i")); });
// ---- attachments -------------------------------------------------------
function renderAttachRow() {
const row = $("attachRow");
if (!pending.length) { row.style.display = "none"; row.innerHTML = ""; return; }
row.style.display = "flex";
row.innerHTML = pending.map((p, i) => `
${I.x}`).join("");
}
$("attachRow").addEventListener("click", (e) => { const rm = e.target.closest("[data-rm]"); if (rm) { pending.splice(+rm.getAttribute("data-rm"), 1); renderAttachRow(); } });
function addFile(file) {
if (!file || !file.type.startsWith("image/")) return;
const reader = new FileReader();
reader.onload = () => { pending.push({ dataUrl: reader.result, media: file.type, name: file.name || "image" }); renderAttachRow(); };
reader.readAsDataURL(file);
}
$("attachBtn").addEventListener("click", () => $("fileInput").click());
$("fileInput").addEventListener("change", (e) => { for (const f of e.target.files) addFile(f); e.target.value = ""; });
inputEl.addEventListener("paste", (e) => { const items = (e.clipboardData || {}).items || []; for (const it of items) { if (it.kind === "file" && it.type.startsWith("image/")) { addFile(it.getAsFile()); e.preventDefault(); } } });
// drag & drop onto composer
const composer = $("composer");
["dragover", "drop"].forEach((ev) => composer.addEventListener(ev, (e) => { e.preventDefault(); if (ev === "drop") for (const f of e.dataTransfer.files) addFile(f); }));
// ---- composer input ----------------------------------------------------
function autoGrow() { inputEl.style.height = "auto"; inputEl.style.height = Math.min(inputEl.scrollHeight, 200) + "px"; }
inputEl.addEventListener("input", () => {
autoGrow();
const tok = currentToken();
if (tok && tok.q.length >= 0 && !tok.q.startsWith("/")) openMention(tok.q);
else if (tok && tok.q.startsWith("/")) openMention(tok.q);
else closeMention();
});
inputEl.addEventListener("focus", () => composer.classList.add("focus"));
inputEl.addEventListener("blur", () => { composer.classList.remove("focus"); setTimeout(closeMention, 150); });
inputEl.addEventListener("keydown", (e) => {
if (mentionPop.style.display === "block" && menItems.length) {
if (e.key === "ArrowDown") { e.preventDefault(); menHl = (menHl + 1) % menItems.length; renderMention(); return; }
if (e.key === "ArrowUp") { e.preventDefault(); menHl = (menHl - 1 + menItems.length) % menItems.length; renderMention(); return; }
if (e.key === "Enter" || e.key === "Tab") { e.preventDefault(); acceptMention(menHl); return; }
if (e.key === "Escape") { e.preventDefault(); closeMention(); return; }
}
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); }
});
$("mentionBtn").addEventListener("click", () => { const pos = inputEl.value.length; inputEl.value += (inputEl.value.endsWith(" ") || !inputEl.value ? "" : " ") + "@"; inputEl.focus(); autoGrow(); openMention(""); });
sendEl.addEventListener("click", send);
$("modelBtn").addEventListener("click", toggleModelPop);
$("newChat").addEventListener("click", newChat);
$("userRow").addEventListener("click", () => {
const open = $("userPop").style.display === "block";
$("userPop").style.display = open ? "none" : "block";
$("userRow").classList.toggle("open", !open);
});
$("settingsBtn").addEventListener("click", openSettings);
$("saveSettings").addEventListener("click", saveSettings);
$("soundBtn").addEventListener("click", async () => {
try {
const settings = await loadSettings();
if (!settings.elevenlabs.configured) { openSettings(); toast("Add your ElevenLabs API key first"); return; }
openModal("soundModal"); $("soundPrompt").focus();
} catch { openSettings(); }
});
$("generateSound").addEventListener("click", generateSounds);
$("soundPrompt").addEventListener("input", () => { $("soundPromptCount").textContent = $("soundPrompt").value.length; });
$("soundLoop").addEventListener("click", () => setSoundLoop(!$("soundLoop").classList.contains("on")));
$("soundInfluence").addEventListener("input", () => { $("soundInfluenceValue").textContent = $("soundInfluence").value + "%"; });
$("textFixBtn").addEventListener("click", () => runDraftTool("/api/text-tools/improve", {}, $("textFixBtn"), "Fixing..."));
$("translateBtn").addEventListener("click", async () => {
if (!inputEl.value.trim()) { toast("Write a draft first"); inputEl.focus(); return; }
try {
const settings = await loadSettings();
const languages = settings.textTools?.translateLanguages || [];
$("translateLanguage").innerHTML = languages.map((language) => ``).join("");
openModal("translateModal");
} catch { toast("Could not load translation languages"); }
});
$("translateDraft").addEventListener("click", async () => {
closeModal("translateModal");
await runDraftTool("/api/text-tools/translate", { language: $("translateLanguage").value }, $("translateBtn"), "Translating...");
});
$("codexLogin").addEventListener("click", async () => {
const result = await fetch("/api/integrations/codex/login", { method: "POST" }).then((r) => r.json()).catch(() => ({ ok: false }));
toast(result.ok ? "Codex OAuth login opened" : "Could not start Codex OAuth login");
});
document.querySelectorAll("[data-close-modal]").forEach((btn) => btn.addEventListener("click", () => closeModal(btn.getAttribute("data-close-modal"))));
document.querySelectorAll(".modal-backdrop").forEach((backdrop) => backdrop.addEventListener("click", (e) => { if (e.target === backdrop) closeModal(backdrop.id); }));
document.addEventListener("click", (e) => {
if (!e.target.closest("#userRow") && !e.target.closest("#userPop")) { $("userPop").style.display = "none"; $("userRow").classList.remove("open"); }
});
document.addEventListener("keydown", (e) => { if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "n") { e.preventDefault(); newChat(); } });
// ---- boot --------------------------------------------------------------
mountIcons();
loadChats();
refreshHealth().then(() => pollTools(5));
if (chats.length && activeId && activeChat()) { switchChat(activeId); } else if (chats.length) { switchChat(chats[0].id); } else { newChat(); }
inputEl.focus();
})();