// 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 = `
ue-blueprintSELF-HOST${ueToolCount} tools
`; } // The startup tool-prime can race the MCP subprocess connecting; poll health // a few times until the ue-blueprint tools show up. function pollTools(n) { if (ueToolCount || n <= 0) return; setTimeout(() => refreshHealth().then(() => pollTools(n - 1)), 3000); } function toast(t) { toastEl.textContent = t; toastEl.classList.add("show"); clearTimeout(toast._t); toast._t = setTimeout(() => toastEl.classList.remove("show"), 1800); } // ---- text rendering ---------------------------------------------------- function esc(s) { return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } const ASSET_RE = /(\/Game\/[A-Za-z0-9_\/]+(?:\.[A-Za-z0-9_]+)?)/g; const MENTION_RE = /@([A-Za-z0-9_\/\.]+)/g; function chipsInSegment(seg) { seg = seg.replace(MENTION_RE, (m, name) => { const isAsset = name.startsWith("/Game/"); const leaf = name.split("/").pop().split(".")[0]; return `${I.mention}${esc(leaf)}`; }); seg = seg.replace(ASSET_RE, (m) => { const path = m.replace(/\.$/, ""); const leaf = path.split("/").pop(); return `${I.asset}${esc(leaf)}`; }); return seg; } function renderMarkdown(text) { const parts = text.split(/```/); let html = ""; for (let i = 0; i < parts.length; i++) { if (i % 2 === 1) { html += `
${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}`)}${Math.max(1, Math.round((s.bytes || 0) / 1024))} KB
0:000:00${I.volume}
`).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}
Готово · ${m.steps.length} ${plural(m.steps.length)}
${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 += `
${I.chat}${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 = `
${I.brand}

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(); })();