Files
unreal-engine-mcp-system-pl…/agent-gateway/public/app.js
Bonchellon eba71c4ca8 Unified, portable Unreal Engine MCP system plugin
Merge of the two project copies into one self-contained plugin (the superset:
variable_op + variables.py, full pcg_op runtime/declarative/preset ops, the
CreateWidgetFloatAnimation widget tool, and full Voxel graph authoring).

Made fully project- and machine-agnostic — no hardcoded paths:
- New src/projectPaths.js auto-detects the host .uproject (walk-up), project
  name, Editor build target, log file, and engine install (EngineAssociation
  via launcher manifest/registry, else installed-engine scan). All overridable
  via UE_* env vars.
- Rewired buildOrchestrator/insights/launcher/insightsExporter/server.js and the
  Python workers (cpp_scaffold, live_coding, apply_graph, console) off the old
  C:/Github/ihy, IHY*, E:/UE_Versions and UE_5.7 literals onto the resolver.
- Voxel made optional: Build.cs auto-detects the Voxel plugin (env
  UE_MCP_WITH_VOXEL override) and the C++ compiles to stubs under WITH_MCP_VOXEL,
  so the module builds in projects without Voxel; .uplugin marks Voxel optional.
- De-branded the agent-gateway and docs; scrubbed a leaked API key; excluded
  node_modules/Binaries/Intermediate/__pycache__/secrets from the repo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 01:07:24 +03:00

700 lines
50 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// In-engine agent — "Forge" client (EventSource transport for UE CEF).
(() => {
"use strict";
const $ = (id) => document.getElementById(id);
const I = {
brand: '<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M12 2l8 4.5v9L12 20l-8-4.5v-9z"/><path d="M12 11l8-4.5M12 11v9M12 11L4 6.5"/></svg>',
history: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M3 12a9 9 0 1 0 3-6.7L3 8"/><path d="M3 4v4h4"/><path d="M12 8v4l3 2"/></svg>',
plus: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>',
search: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4-4"/></svg>',
server: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01M7 16.5h.01"/></svg>',
mention: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="4"/><path d="M16 12v1.5a2.5 2.5 0 0 0 5 0V12a9 9 0 1 0-3.5 7.1"/></svg>',
tools: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.7 6.3a4 4 0 0 0-5.4 5.4L3 18v3h3l6.3-6.3a4 4 0 0 0 5.4-5.4l-2.5 2.5-2.4-.6-.6-2.4z"/></svg>',
clip: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 11.5l-8.4 8.4a5 5 0 0 1-7.1-7.1l8.4-8.4a3.3 3.3 0 0 1 4.7 4.7l-8.4 8.4a1.7 1.7 0 0 1-2.4-2.4l7.7-7.7"/></svg>',
send: '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M3.4 20.6l17.4-8a1 1 0 0 0 0-1.8l-17.4-8A1 1 0 0 0 2 3.7L4.2 11l9.8 1-9.8 1L2 20.3a1 1 0 0 0 1.4 1.1z"/></svg>',
check: '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5L19 6"/></svg>',
x: '<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>',
chev: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>',
asset: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M12 2l8 4.5v9L12 20l-8-4.5v-9z"/><path d="M12 11l8-4.5M12 11v9M12 11L4 6.5"/></svg>',
chat: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"><path d="M21 12a8 8 0 0 1-8 8H4l3-3a8 8 0 1 1 14-5z"/></svg>',
spark: '<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2l1.8 6.2L20 10l-6.2 1.8L12 18l-1.8-6.2L4 10l6.2-1.8z"/></svg>',
trash: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 7h16M9 7V5h6v2M6 7l1 13h10l1-13"/></svg>',
gear: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.8 1.8 0 0 0 .36 1.98l.04.04-2.78 2.78-.04-.04A1.8 1.8 0 0 0 15 19.4a1.8 1.8 0 0 0-1 .6l-.06.06H10.1l-.06-.06a1.8 1.8 0 0 0-1-.6 1.8 1.8 0 0 0-1.98.36l-.04.04-2.78-2.78.04-.04A1.8 1.8 0 0 0 4.6 15a1.8 1.8 0 0 0-.6-1l-.06-.06V10.1l.06-.06a1.8 1.8 0 0 0 .6-1 1.8 1.8 0 0 0-.36-1.98l-.04-.04 2.78-2.78.04.04A1.8 1.8 0 0 0 9 4.6a1.8 1.8 0 0 0 1-.6l.06-.06h3.84l.06.06a1.8 1.8 0 0 0 1 .6 1.8 1.8 0 0 0 1.98-.36l.04-.04 2.78 2.78-.04.04A1.8 1.8 0 0 0 19.4 9c.1.36.3.7.6 1l.06.06v3.84l-.06.06c-.3.3-.5.64-.6 1z"/></svg>',
volume: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5L6 9H3v6h3l5 4z"/><path d="M15.5 8.5a5 5 0 0 1 0 7M18 6a8 8 0 0 1 0 12"/></svg>',
download: '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12M7 10l5 5 5-5M5 21h14"/></svg>',
play: '<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M7 4.8v14.4a1 1 0 0 0 1.55.83l10.1-7.2a1 1 0 0 0 0-1.66L8.55 3.97A1 1 0 0 0 7 4.8z"/></svg>',
pause: '<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M7 5h4v14H7zM13 5h4v14h-4z"/></svg>',
textfix: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 5h10M4 10h7M4 15h6"/><path d="M14 18l2 2 4-5"/></svg>',
translate: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 4h7M8.5 3v2M4 9h9M11 5c-1 4-3.2 6.5-6 8"/><path d="M8 10c1 1.4 2.2 2.5 4 3.5M14 20l4-9 4 9M15.5 17h5"/></svg>',
};
// ---- 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 = `<div class="mcp-srv"><span class="dot on"></span><span class="srv-name">ue-blueprint</span><span class="srv-self">SELF-HOST</span><span class="srv-tools">${ueToolCount} tools</span></div>`;
}
// 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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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 `<span class="mention" data-asset="${isAsset ? name : ""}" data-copy="${esc(name)}">${I.mention}${esc(leaf)}</span>`;
});
seg = seg.replace(ASSET_RE, (m) => {
const path = m.replace(/\.$/, ""); const leaf = path.split("/").pop();
return `<span class="objlink" data-asset="${path}" title="${esc(path)}"><span class="ol-ico">${I.asset}</span>${esc(leaf)}</span>`;
});
return seg;
}
function renderMarkdown(text) {
const parts = text.split(/```/); let html = "";
for (let i = 0; i < parts.length; i++) {
if (i % 2 === 1) { html += `<pre><code>${esc(parts[i].replace(/^[a-zA-Z0-9]*\n/, ""))}</code></pre>`; }
else {
let seg = esc(parts[i]);
seg = seg.replace(/`([^`]+)`/g, (_, c) => `<code>${c}</code>`);
seg = seg.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
seg = seg.split(/(<code>.*?<\/code>)/g).map((s) => (s.startsWith("<code>") ? s : chipsInSegment(s))).join("");
html += seg.replace(/\n/g, "<br>");
}
}
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 `<div class="msg-images">${images.map((d) => `<img src="${d}" />`).join("")}</div>`;
}
function addUserMessage(text, images) {
const el = document.createElement("div"); el.className = "msg user-msg";
el.innerHTML = `<div class="msg-avatar user">EX</div><div class="msg-body"><div class="msg-name">Вы <span class="ts">${nowStr()}</span></div>` +
imagesHtml(images) + `<div class="msg-text">${renderMarkdown(text)}</div></div>`;
threadEl.appendChild(el); scrollDown();
}
function addAgentShell() {
const el = document.createElement("div"); el.className = "msg";
el.innerHTML = `<div class="msg-avatar ai">${I.brand}</div><div class="msg-body"><div class="msg-name">Forge <span class="ts">${($("modelName").textContent)}</span></div></div>`;
threadEl.appendChild(el);
return { el, body: el.querySelector(".msg-body") };
}
function audioSetHtml(message) {
const sounds = message.sounds || [];
return `<div class="audio-prompt">ElevenLabs variations for: <strong>${esc(message.prompt || "")}</strong></div><div class="audio-set">` +
sounds.map((s, i) => `<div class="audio-card" data-audio-card><div class="audio-card-head"><span class="audio-badge">${I.volume}</span><span class="audio-name">${esc(s.name || `variation_${i + 1}`)}</span><span class="audio-meta">${Math.max(1, Math.round((s.bytes || 0) / 1024))} KB</span><button class="import-btn ${s.imported ? "done" : ""}" data-import-audio="${esc(s.id)}">${s.imported ? "Imported" : `${I.download} Import`}</button></div><div class="forge-player"><button class="player-play" data-player-play title="Play">${I.play}</button><span class="player-time" data-player-current>0:00</span><input class="player-progress" data-player-progress type="range" min="0" max="1000" value="0" step="1" aria-label="Audio progress" /><span class="player-time" data-player-duration>0:00</span><span class="player-volume-icon">${I.volume}</span><input class="player-volume" data-player-volume type="range" min="0" max="100" value="85" step="1" aria-label="Volume" /><audio data-player-audio preload="metadata" src="${esc(s.audioUrl)}"></audio></div></div>`).join("") +
`</div>`;
}
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 `<span class="objlink" data-asset="${esc(tgt)}" title="${esc(tgt)}"><span class="ol-ico">${I.asset}</span>${esc(tgt.split("/").pop())}</span>`;
if (/^[A-Za-z0-9_]+$/.test(tgt) && tgt !== base) return `<span class="objlink"><span class="ol-ico">${I.asset}</span>${esc(tgt)}</span>`;
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 = `<div class="console-head"><div class="console-spin"></div><div class="console-title">Работаю… <span class="console-meta"></span></div><span class="console-chev">${I.chev}</span></div><div class="console-body"><div class="console-inner"></div></div>`;
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 = `<div class="step-node">${turn.nStep}</div><div class="step-main"><div class="step-row"><div class="step-verb"><span class="v">${esc(verb)}</span>${chipFor(tgt, base)}<span class="step-tool">${esc(toolLabel(name))}</span></div><span class="step-toggle" style="display:none">details</span><span class="step-dur"></span></div><div class="collapsible"><div><div class="tool-detail" style="display:none"></div></div></div></div>`;
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 = `<div class="td-head">результат</div><div class="td-body">${esc(text.length > 6000 ? text.slice(0, 6000) + "\n…" : text)}</div>`;
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 = `<span class="console-check">${I.check}</span>`;
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) => `<div class="step ${s.ok === false ? "err" : "done"}"><div class="step-node">${s.ok === false ? I.x : I.check}</div><div class="step-main"><div class="step-row"><div class="step-verb"><span class="v">${esc(s.verb || "")}</span>${chipFor(s.tgt, "")}<span class="step-tool">${esc(s.tool || "")}</span></div><span class="step-dur">${esc(s.dur || "")}</span></div></div></div>`).join("");
c.innerHTML = `<div class="console-head"><span class="console-check">${I.check}</span><div class="console-title">Готово <span class="console-meta">· ${m.steps.length} ${plural(m.steps.length)}</span></div><span class="console-chev">${I.chev}</span></div><div class="console-body"><div class="console-inner">${rows}</div></div>`;
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 += `<div class="sb-group-label">${g}</div>`;
for (const c of groups[g]) html += `<div class="chat-item ${c.id === activeId ? "active" : ""}" data-chat="${c.id}"><span class="ci-icon">${I.chat}</span><span class="chat-title">${esc(c.title)}</span><span class="ci-act" data-del="${c.id}">${I.trash}</span></div>`;
}
$("sbScroll").innerHTML = html || `<div class="sb-group-label">Сессии</div>`;
}
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 = `<div><div class="es-icon">${I.brand}</div><h2>Forge — копилот внутри движка</h2><p>Я работаю с проектом через ue-blueprint MCP и могу править Blueprints, спавнить ноды, билдить C++, делать скриншоты UMG.</p><div class="suggest-grid">${sugg("Пингани UE", "проверь связь и активный Blueprint")}${sugg("Список MCP-инструментов", "что ты умеешь")}${sugg("Опиши активный Blueprint", "разбери граф событий")}${sugg("Открой /Game", "покажи структуру проекта")}</div></div>`;
threadEl.appendChild(es);
es.querySelectorAll(".suggest").forEach((s) => s.addEventListener("click", () => { inputEl.value = s.dataset.q; autoGrow(); inputEl.focus(); }));
}
function sugg(t, d) { return `<button class="suggest" data-q="${esc(t)}"><div class="sg-title">${esc(t)}</div><div class="sg-desc">${esc(d)}</div></button>`; }
// ---- 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", `<div class="err-text">⚠ ${esc(data.message || "ошибка")}</div>`); 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", `<div class="err-text">⚠ потеряна связь с гейтвеем</div>`); 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 = `<span class="console-spin"></span><span>Generating ElevenLabs variations...</span>`;
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 = `<span class="err-text">${esc(e.message || "Sound generation failed")}</span>`;
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 = `<div class="pop-label">Модель</div>` + MODELS.map((m) =>
`<div class="pop-item ${m.disabled ? "disabled" : ""}" data-model="${m.id}" data-disabled="${m.disabled ? "1" : ""}"><span class="m-glyph" style="width:18px;height:18px">${I.spark}</span><div class="pi-main"><div class="pi-name">${esc(m.name)}</div><div class="pi-desc">${esc(m.tag || m.id)}</div></div>${m.id === model ? `<span class="pi-check">${I.check}</span>` : ""}</div>`).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 = `<div class="mp-empty">ничего не найдено</div>`; mentionPop.style.display = "block"; return; }
mentionPop.innerHTML = menItems.map((a, i) =>
`<div class="mp-row ${i === menHl ? "hl" : ""}" data-i="${i}"><span class="mp-ico">${I.asset}</span><div class="mp-main"><div class="mp-name">${esc(a.name)} ${a.open ? '<span class="mp-open">OPEN</span>' : ""}</div><div class="mp-path">${esc(a.path)}</div></div><span class="mp-cls">${esc(a.class || "")}</span></div>`).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) => `<div class="attach-thumb"><img src="${p.dataUrl}" /><span class="at-x" data-rm="${i}">${I.x}</span></div>`).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) => `<option>${esc(language)}</option>`).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();
})();