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>
700 lines
50 KiB
JavaScript
700 lines
50 KiB
JavaScript
// 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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();
|
||
})();
|