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>
This commit is contained in:
Bonchellon
2026-06-24 01:07:24 +03:00
parent d46e03e53e
commit eba71c4ca8
76 changed files with 23838 additions and 1 deletions

699
agent-gateway/public/app.js Normal file
View File

@ -0,0 +1,699 @@
// 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();
})();

View File

@ -0,0 +1,194 @@
<!DOCTYPE html>
<html lang="en" data-theme="eclipse">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Unreal Copilot</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div class="app">
<!-- ============ SIDEBAR ============ -->
<aside class="sidebar">
<div class="sb-head">
<div class="brand">
<div class="brand-mark" id="brandMark"></div>
<div>
<div class="brand-name">Unreal Copilot</div>
<div class="brand-sub">ue-blueprint MCP</div>
</div>
</div>
<button class="icon-btn" id="historyBtn" title="История"></button>
</div>
<button class="new-chat" id="newChat">
<span id="newChatIcon"></span>
<span>Новый чат</span>
<span class="kbd">Ctrl N</span>
</button>
<div class="sb-search">
<span id="searchIcon"></span>
<input id="search" placeholder="Поиск по чатам…" />
</div>
<div class="sb-scroll" id="sbScroll"></div>
<div class="sb-foot">
<div class="mcp-card">
<div class="mcp-head" id="mcpHead">
<span id="mcpIcon"></span>
<span class="mcp-title">MCP Servers</span>
<span class="mcp-count" id="mcpCount"></span>
</div>
<div class="mcp-list" id="mcpList"></div>
</div>
<div class="user-row" id="userRow">
<div class="avatar" id="userAvatar">EX</div>
<div class="user-copy">
<div class="user-name">Exbyte Studios</div>
<div class="user-plan">Studio · Pro</div>
</div>
<span class="user-chev" id="userChev"></span>
</div>
<div class="user-pop pop" id="userPop" style="display:none">
<div class="pop-label">Workspace</div>
<div class="pop-item" id="settingsBtn"><span class="pop-ico" id="settingsIcon"></span><div class="pi-main"><div class="pi-name">Settings</div><div class="pi-desc">Integrations and import paths</div></div></div>
</div>
</div>
</aside>
<!-- ============ MAIN ============ -->
<main class="main">
<header class="topbar">
<div class="tb-titles">
<div class="tb-title" id="convTitle">Новый разговор</div>
<div class="tb-sub" id="convSub">ue-blueprint MCP</div>
</div>
<div class="tb-spacer"></div>
<div class="status-pill" id="statusPill"><span class="dot off" id="statusDot"></span><span id="statusText">connecting…</span></div>
<div style="position:relative">
<button class="model-btn" id="modelBtn">
<span class="m-glyph" id="modelGlyph"></span>
<span id="modelName">agent</span>
<span id="modelChev" style="color:var(--text-dim)"></span>
</button>
<div class="pop" id="modelPop" style="display:none; right:0; top:40px;"></div>
</div>
</header>
<div class="thread-wrap" id="threadWrap">
<div class="thread" id="thread"></div>
</div>
<div class="composer-wrap">
<div class="composer-inner">
<div class="attach-row" id="attachRow" style="display:none"></div>
<div class="composer" id="composer">
<textarea class="composer-field" id="input" rows="1" placeholder="Попроси Forge отредактировать Blueprint или @упомяни ассет…"></textarea>
<div class="composer-bar">
<button class="cb-btn" id="mentionBtn"><span id="mentionIcon"></span> Mention</button>
<button class="cb-btn" id="attachBtn"><span id="attachIcon"></span> Attach</button>
<button class="cb-btn" id="toolsBtn"><span id="toolsIcon"></span> Tools <span id="toolsCount" style="color:var(--text-dim)"></span></button>
<button class="cb-btn" id="soundBtn"><span id="soundIcon"></span> Sound</button>
<button class="cb-btn" id="textFixBtn"><span id="textFixIcon"></span> Text Fix</button>
<button class="cb-btn" id="translateBtn"><span id="translateIcon"></span> Translate</button>
<span class="cb-spacer"></span>
<button class="send-btn" id="send" title="Отправить"><span id="sendIcon"></span></button>
</div>
</div>
<div class="mention-pop pop" id="mentionPop" style="display:none"></div>
<div class="composer-hint">Forge правит Blueprints и C++ через <strong style="color:var(--text-muted);font-weight:600">ue-blueprint</strong> MCP · <span class="kbd"></span> отправить · <span class="kbd">⇧⏎</span> перенос</div>
</div>
</div>
</main>
</div>
<input type="file" id="fileInput" accept="image/*" multiple style="display:none" />
<div class="modal-backdrop" id="settingsModal" style="display:none">
<div class="modal settings-modal">
<div class="modal-head">
<div><div class="modal-title">Settings</div><div class="modal-sub">Forge workspace configuration</div></div>
<button class="modal-close" data-close-modal="settingsModal">x</button>
</div>
<div class="settings-body">
<div class="settings-nav">
<button class="settings-nav-item active"><span id="integrationsIcon"></span> Integrations</button>
</div>
<div class="settings-content">
<div class="settings-kicker">Integrations</div>
<h2>Connected services</h2>
<p class="settings-intro">Secrets are stored locally beside the Forge gateway and never returned to the browser.</p>
<section class="integration-card">
<div class="integration-head">
<div class="integration-logo eleven">XI</div>
<div class="integration-meta"><h3>ElevenLabs</h3><p>Generate sound-effect variations and import selected WAV assets into Unreal.</p></div>
<span class="integration-state" id="elevenState">Not configured</span>
</div>
<label class="field"><span>API key</span><input id="elevenApiKey" type="password" autocomplete="off" placeholder="xi-api-key" /><small id="elevenKeyHint">Enter a key to connect ElevenLabs.</small></label>
<div class="field-grid">
<label class="field"><span>Unreal import folder</span><input id="elevenImportPath" value="/Game/Audio/Generated" /></label>
<label class="field"><span>Variations per prompt</span><select id="elevenVariations"><option>1</option><option>2</option><option selected>3</option><option>4</option></select></label>
</div>
<label class="field"><span>Sound Effects model</span><select id="elevenSoundModel"><option value="eleven_text_to_sound_v2">Sound Effects v2 · eleven_text_to_sound_v2</option></select><small>Current ElevenLabs flagship sound-effects model. Looping is supported by v2.</small></label>
<div class="field-grid">
<label class="field"><span>Default duration</span><select id="elevenDefaultDuration"><option value="">Auto</option><option value="1">1s</option><option value="2">2s</option><option value="3">3s</option><option value="5">5s</option><option value="10">10s</option><option value="15">15s</option><option value="30">30s</option></select></label>
<label class="field"><span>Default prompt influence</span><select id="elevenDefaultInfluence"><option value="0.2">20% · more variety</option><option value="0.3">30% · balanced</option><option value="0.5">50% · closer prompt</option><option value="0.7">70% · strict prompt</option><option value="0.9">90% · very strict</option></select></label>
</div>
</section>
<section class="integration-card">
<div class="integration-head">
<div class="integration-logo codex">&lt;/&gt;</div>
<div class="integration-meta"><h3>Codex</h3><p>Authorize Codex locally through OAuth. Forge only reads the connection state.</p></div>
<span class="integration-state" id="codexState">Checking...</span>
</div>
<div class="integration-actions"><button class="secondary-btn" id="codexLogin">Authorize with OAuth</button><span class="integration-note">OAuth credentials stay in <code>~/.codex/auth.json</code>.</span></div>
</section>
<section class="integration-card">
<div class="integration-head">
<div class="integration-logo textfix">Aa</div>
<div class="integration-meta"><h3>Text Fix Improver</h3><p>Improve composer drafts with your own editing instruction.</p></div>
<span class="integration-state connected">Enabled</span>
</div>
<label class="field"><span>Improvement prompt</span><textarea id="textFixPrompt" rows="4" maxlength="2000"></textarea><small>Forge returns only the improved text and replaces the current draft.</small></label>
<label class="field"><span>Translate languages</span><input id="translateLanguages" placeholder="English, Russian, German" /><small>Comma-separated languages shown in the Translate dialog.</small></label>
</section>
</div>
</div>
<div class="modal-foot"><button class="secondary-btn" data-close-modal="settingsModal">Cancel</button><button class="primary-btn" id="saveSettings">Save changes</button></div>
</div>
</div>
<div class="modal-backdrop" id="soundModal" style="display:none">
<div class="modal sound-modal">
<div class="modal-head">
<div><div class="modal-title">Generate sound effects</div><div class="modal-sub">ElevenLabs creates multiple variations for review in this chat.</div></div>
<button class="modal-close" data-close-modal="soundModal">x</button>
</div>
<div class="sound-modal-body">
<label class="field"><span>Sound prompt</span><textarea id="soundPrompt" rows="5" maxlength="450" placeholder="Heavy metal door slamming shut in a damp industrial corridor, horror game one-shot"></textarea><small><span id="soundPromptCount">0</span>/450 characters</small></label>
<div class="sound-options">
<label class="sound-option"><span class="sound-option-label">Loop</span><button class="toggle-btn" id="soundLoop" type="button" aria-pressed="false"><span class="toggle-track"><span class="toggle-knob"></span></span><strong>Off</strong></button></label>
<label class="sound-option"><span class="sound-option-label">Duration</span><select id="soundDuration"><option value="">Auto</option><option value="1">1s</option><option value="2">2s</option><option value="3">3s</option><option value="5">5s</option><option value="10">10s</option><option value="15">15s</option><option value="30">30s</option></select></label>
<label class="sound-option influence"><span class="sound-option-label">Prompt influence</span><div class="range-row"><input id="soundInfluence" type="range" min="0" max="100" value="30" step="5" /><strong id="soundInfluenceValue">30%</strong></div></label>
<label class="sound-option compact"><span class="sound-option-label">Variations</span><select id="soundVariations"><option>1</option><option>2</option><option selected>3</option><option>4</option></select></label>
</div>
</div>
<div class="modal-foot"><button class="secondary-btn" data-close-modal="soundModal">Cancel</button><button class="primary-btn" id="generateSound">Generate variations</button></div>
</div>
</div>
<div class="modal-backdrop" id="translateModal" style="display:none">
<div class="modal translate-modal">
<div class="modal-head">
<div><div class="modal-title">Translate draft</div><div class="modal-sub">Choose the language for the current composer text.</div></div>
<button class="modal-close" data-close-modal="translateModal">x</button>
</div>
<div class="sound-modal-body">
<label class="field"><span>Target language</span><select id="translateLanguage"></select></label>
</div>
<div class="modal-foot"><button class="secondary-btn" data-close-modal="translateModal">Cancel</button><button class="primary-btn" id="translateDraft">Translate</button></div>
</div>
</div>
<div class="toast" id="toast"></div>
<script src="app.js"></script>
</body>
</html>

View File

@ -0,0 +1,369 @@
/* ============================================================
In-engine agent — "Forge / Unreal Copilot" eclipse theme.
Adapted for UE's CEF WebBrowser: no backdrop-filter / color-mix /
oklch (CPU-composited, possibly older Chromium) — explicit colors only.
All visual tokens live in :root.
No web-font @import — UE's CEF may have no/slow internet and a render-blocking
font fetch stalls the whole UI. System fonts only.
============================================================ */
:root {
--font-ui: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
--font-mono: ui-monospace, "Cascadia Code", "Cascadia Mono", Consolas, monospace;
--radius-sm: 7px;
--radius: 11px;
--radius-lg: 16px;
--accent: #7C6BD6;
--accent-2: #9D7BFF;
--accent-soft: rgba(124,107,214,0.16);
--accent-line: rgba(124,107,214,0.40);
--bg: #0a0a0c;
--bg-grad: radial-gradient(120% 90% at 80% -10%, #15131c 0%, #0a0a0c 55%);
--bg-elev: #0d0d10;
--surface: #141418;
--surface-2: #1a1a20;
--surface-3: #212129;
--surface-trans: rgba(20,20,24,0.70);
--border: rgba(255,255,255,0.07);
--border-strong: rgba(255,255,255,0.12);
--text: #ECECF0;
--text-muted: #9a9aa6;
--text-dim: #62626e;
--ok: #4ec98f;
--warn: #e6b450;
--err: #ff7a7a;
--shadow: 0 1px 2px rgba(0,0,0,0.4), 0 8px 28px -12px rgba(0,0,0,0.55);
--shadow-pop: 0 18px 50px -16px rgba(0,0,0,0.7);
--sidebar-w: 276px;
--ease: cubic-bezier(0.32, 0.72, 0, 1);
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
}
* { box-sizing: border-box; }
html, body {
margin: 0; height: 100%;
background: var(--bg);
color: var(--text);
font-family: var(--font-ui);
font-size: 14px;
-webkit-font-smoothing: antialiased;
}
body { overflow: hidden; }
::selection { background: var(--accent-soft); color: var(--text); }
button { font-family: inherit; cursor: pointer; border: none; background: none; color: inherit; }
input, textarea { font-family: inherit; }
*::-webkit-scrollbar { width: 10px; height: 10px; }
*::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 99px; border: 3px solid transparent; background-clip: padding-box; }
*::-webkit-scrollbar-thumb:hover { background: var(--text-dim); background-clip: padding-box; }
*::-webkit-scrollbar-track { background: transparent; }
.app {
height: 100vh;
display: grid;
grid-template-columns: var(--sidebar-w) 1fr;
background: var(--bg-grad), var(--bg);
}
/* ---------------- SIDEBAR ---------------- */
.sidebar { background: var(--bg-elev); border-right: 1px solid var(--border); display: flex; flex-direction: column; min-width: 0; height: 100vh; }
.sb-head { padding: 16px 16px 12px; display: flex; align-items: center; gap: 10px; }
.brand { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; }
.brand-mark { width: 30px; height: 30px; border-radius: 9px; flex-shrink: 0; display: grid; place-items: center;
background: linear-gradient(150deg, var(--accent) 0%, var(--accent-2) 100%); color: #fff; }
.brand-name { font-weight: 600; font-size: 15px; letter-spacing: -0.01em; }
.brand-sub { font-size: 11px; color: var(--text-dim); margin-top: -1px; letter-spacing: 0.02em; }
.icon-btn { width: 30px; height: 30px; border-radius: var(--radius-sm); display: grid; place-items: center; color: var(--text-muted); transition: background .15s, color .15s; }
.icon-btn:hover { background: var(--surface-2); color: var(--text); }
.new-chat { margin: 4px 12px 8px; padding: 9px 12px; display: flex; align-items: center; gap: 9px;
border-radius: var(--radius-sm); font-size: 13.5px; font-weight: 500; color: var(--text);
background: var(--surface); border: 1px solid var(--border); transition: border-color .15s, background .15s, transform .08s; }
.new-chat:hover { border-color: var(--accent-line); background: var(--surface-2); }
.new-chat:active { transform: translateY(1px); }
.new-chat .kbd { margin-left: auto; }
.kbd { font-family: var(--font-mono); font-size: 10.5px; color: var(--text-dim); border: 1px solid var(--border); border-radius: 5px; padding: 1px 5px; background: var(--bg); }
.sb-search { margin: 2px 12px 8px; display: flex; align-items: center; gap: 8px; padding: 7px 10px;
border-radius: var(--radius-sm); background: var(--bg); border: 1px solid var(--border); color: var(--text-dim); }
.sb-search input { flex: 1; background: none; border: none; outline: none; color: var(--text); font-size: 13px; }
.sb-search input::placeholder { color: var(--text-dim); }
.sb-scroll { flex: 1; overflow-y: auto; padding: 4px 8px 8px; }
.sb-group-label { font-size: 10.5px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-dim); padding: 14px 8px 5px; }
.chat-item { display: flex; align-items: center; gap: 9px; padding: 7px 9px; border-radius: var(--radius-sm); cursor: pointer;
color: var(--text-muted); font-size: 13px; position: relative; transition: background .13s, color .13s; }
.chat-item:hover { background: var(--surface); color: var(--text); }
.chat-item.active { background: var(--surface-2); color: var(--text); }
.chat-item.active::before { content: ''; position: absolute; left: -8px; top: 50%; transform: translateY(-50%); width: 3px; height: 16px; border-radius: 0 3px 3px 0; background: var(--accent); }
.chat-item .ci-icon { color: var(--text-dim); flex-shrink: 0; display: grid; place-items: center; }
.chat-item.active .ci-icon { color: var(--accent-2); }
.chat-title { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sb-foot { border-top: 1px solid var(--border); padding: 8px; }
.mcp-card { border-radius: var(--radius); background: var(--surface); border: 1px solid var(--border); overflow: hidden; }
.mcp-head { display: flex; align-items: center; gap: 9px; padding: 10px 11px; cursor: pointer; transition: background .13s; }
.mcp-head:hover { background: var(--surface-2); }
.mcp-title { font-size: 12.5px; font-weight: 600; flex: 1; }
.mcp-count { font-size: 11px; color: var(--text-dim); font-family: var(--font-mono); }
.mcp-list { border-top: 1px solid var(--border); padding: 4px; }
.mcp-srv { display: flex; align-items: center; gap: 9px; padding: 7px 8px; border-radius: var(--radius-sm); font-size: 12.5px; }
.mcp-srv:hover { background: var(--surface-2); }
.mcp-srv .srv-name { flex: 1; }
.mcp-srv .srv-tools { font-size: 10.5px; color: var(--text-dim); font-family: var(--font-mono); }
.srv-self { font-size: 9.5px; font-weight: 600; letter-spacing: .04em; color: var(--accent-2); border: 1px solid var(--accent-line); border-radius: 4px; padding: 1px 5px; }
.dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.dot.on { background: var(--ok); box-shadow: 0 0 0 3px rgba(78,201,143,0.22); }
.dot.off { background: var(--text-dim); }
.dot.warn{ background: var(--warn); }
.user-row { display: flex; align-items: center; gap: 10px; padding: 9px 6px 4px; border-radius: var(--radius-sm); cursor: pointer; transition: background .13s; }
.user-row:hover { background: var(--surface-2); }
.user-copy { flex: 1; min-width: 0; }
.user-chev { display: grid; place-items: center; color: var(--text-dim); transition: transform .16s; }
.user-row.open .user-chev { transform: rotate(180deg); }
.sb-foot { position: relative; }
.user-pop { left: 8px; right: 8px; bottom: 58px; min-width: 0; }
.avatar { width: 28px; height: 28px; border-radius: 8px; background: linear-gradient(135deg,#5b6cff,#c45bff); display: grid; place-items: center; color: #fff; font-size: 12px; font-weight: 600; }
.user-name { font-size: 12.5px; font-weight: 500; }
.user-plan { font-size: 10.5px; color: var(--text-dim); }
/* ---------------- MAIN / TOPBAR ---------------- */
.main { display: flex; flex-direction: column; min-width: 0; height: 100vh; }
.topbar { height: 54px; flex-shrink: 0; display: flex; align-items: center; gap: 12px; padding: 0 16px 0 18px; border-bottom: 1px solid var(--border); background: var(--bg-elev); }
.tb-titles { min-width: 0; }
.tb-title { font-size: 14px; font-weight: 600; letter-spacing: -0.01em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 52vw; }
.tb-sub { font-size: 11.5px; color: var(--text-dim); }
.tb-spacer { flex: 1; }
.model-btn { display: flex; align-items: center; gap: 8px; padding: 6px 9px 6px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border); background: var(--surface); font-size: 12.5px; font-weight: 500; color: var(--text); transition: border-color .15s; }
.model-btn:hover { border-color: var(--accent-line); }
.model-btn .m-glyph { width: 16px; height: 16px; border-radius: 5px; display: grid; place-items: center; background: var(--accent-soft); color: var(--accent-2); }
.status-pill { display: flex; align-items: center; gap: 7px; font-size: 11.5px; color: var(--text-muted); }
/* ---------------- THREAD ---------------- */
.thread-wrap { flex: 1; overflow-y: auto; min-height: 0; }
.thread { max-width: 820px; margin: 0 auto; padding: 28px 28px 40px; display: flex; flex-direction: column; gap: 26px; }
.msg { display: flex; gap: 14px; }
.msg-avatar { width: 30px; height: 30px; border-radius: 9px; flex-shrink: 0; display: grid; place-items: center; }
.msg-avatar.user { background: linear-gradient(135deg,#5b6cff,#c45bff); color: #fff; font-size: 12px; font-weight: 600; }
.msg-avatar.ai { background: linear-gradient(150deg, var(--accent), var(--accent-2)); color: #fff; }
.msg-body { flex: 1; min-width: 0; padding-top: 3px; }
.msg-name { font-size: 12.5px; font-weight: 600; margin-bottom: 7px; display: flex; align-items: center; gap: 8px; }
.msg-name .ts { font-weight: 400; color: var(--text-dim); font-size: 11px; }
.msg-text { font-size: 14.5px; line-height: 1.62; color: var(--text); }
.msg-text p { margin: 0 0 12px; }
.msg-text p:last-child { margin-bottom: 0; }
.msg-text strong { font-weight: 600; }
.msg-text code { font-family: var(--font-mono); font-size: 12.5px; background: var(--surface-2); padding: 2px 6px; border-radius: 5px; border: 1px solid var(--border); }
.msg-text pre { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 11px 13px; overflow-x: auto; margin: 10px 0; }
.msg-text pre code { background: none; border: none; padding: 0; }
.msg.user-msg .msg-text { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 15px; }
/* inline chips */
.mention { display: inline-flex; align-items: center; gap: 4px; vertical-align: baseline; font-family: var(--font-mono); font-size: 12.5px; font-weight: 500;
color: var(--accent-2); background: var(--accent-soft); border: 1px solid var(--accent-line); border-radius: var(--radius-sm); padding: 1px 7px 1px 5px; cursor: pointer; transition: filter .13s; }
.mention:hover { filter: brightness(1.18); }
.objlink { display: inline-flex; align-items: center; gap: 5px; cursor: pointer; font-family: var(--font-mono); font-size: 0.92em; font-weight: 500;
color: var(--text); border-bottom: 1px dashed var(--border-strong); padding-bottom: 1px; transition: color .13s, border-color .13s; }
.objlink:hover { color: var(--accent-2); border-color: var(--accent-line); }
.objlink .ol-ico { color: var(--accent-2); display: inline-grid; place-items: center; }
/* ---------------- CONSOLE (agent process) ---------------- */
.console { border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface-trans); overflow: hidden; margin-bottom: 16px; }
.console-head { display: flex; align-items: center; gap: 10px; padding: 10px 13px; cursor: pointer; user-select: none; transition: background .13s; }
.console-head:hover { background: var(--surface-2); }
.console-spin { width: 15px; height: 15px; flex-shrink: 0; border: 2px solid var(--border-strong); border-top-color: var(--accent-2); border-radius: 50%; animation: spin .8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.console-check { width: 15px; height: 15px; flex-shrink: 0; color: var(--ok); display: grid; place-items: center; }
.console-title { font-size: 12.5px; font-weight: 600; flex: 1; display: flex; align-items: center; gap: 8px; }
.console-meta { font-size: 11px; color: var(--text-dim); font-family: var(--font-mono); }
.console-chev { color: var(--text-dim); transition: transform .2s var(--ease); display: grid; place-items: center; }
.console.collapsed .console-chev { transform: rotate(-90deg); }
.console-body { border-top: 1px solid var(--border); display: grid; grid-template-rows: 1fr; transition: grid-template-rows .28s var(--ease); }
.console.collapsed .console-body { grid-template-rows: 0fr; border-top-color: transparent; }
.console-inner { overflow: hidden; padding: 6px 0; }
.step { position: relative; padding: 3px 14px 3px 38px; }
.step::before { content: ''; position: absolute; left: 21px; top: 0; bottom: 0; width: 1.5px; background: var(--border); }
.step:first-child::before { top: 13px; }
.step:last-child::before { bottom: calc(100% - 14px); }
.step-node { position: absolute; left: 14px; top: 8px; width: 15px; height: 15px; border-radius: 50%; background: var(--bg-elev); border: 1.5px solid var(--border-strong); display: grid; place-items: center; z-index: 1; color: var(--text-muted); }
.step.done .step-node { border-color: var(--accent-line); color: var(--accent-2); background: var(--accent-soft); }
.step.run .step-node { border-color: var(--accent); color: var(--accent-2); }
.step.err .step-node { border-color: var(--err); color: var(--err); }
.step-main { padding: 4px 0; }
.step-row { display: flex; align-items: center; gap: 9px; min-height: 24px; }
.step-verb { font-size: 13px; color: var(--text); display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
.step-verb .v { color: var(--text-muted); }
.step-tool { font-family: var(--font-mono); font-size: 11px; color: var(--text-dim); border: 1px solid var(--border); border-radius: 5px; padding: 1px 6px; }
.step-dur { margin-left: auto; font-size: 10.5px; font-family: var(--font-mono); color: var(--text-dim); }
.step-toggle { font-size: 11px; color: var(--text-dim); display: inline-flex; align-items: center; gap: 4px; cursor: pointer; }
.step-toggle:hover { color: var(--accent-2); }
.think { font-size: 12.5px; line-height: 1.6; color: var(--text-muted); font-style: italic; padding: 8px 0 4px; max-width: 62ch; white-space: pre-wrap; }
.tool-detail { margin: 8px 0 6px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg); overflow: hidden; font-family: var(--font-mono); font-size: 11.5px; line-height: 1.7; }
.td-head { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--border); color: var(--text-dim); font-size: 10.5px; letter-spacing: .03em; }
.td-body { padding: 8px 12px; overflow-x: auto; white-space: pre-wrap; word-break: break-word; max-height: 320px; overflow-y: auto; color: var(--text-muted); }
.collapsible { display: grid; grid-template-rows: 0fr; transition: grid-template-rows .26s var(--ease); }
.collapsible.open { grid-template-rows: 1fr; }
.collapsible > div { overflow: hidden; }
.streaming-caret { display: inline-block; width: 7px; height: 15px; background: var(--accent-2); margin-left: 2px; vertical-align: text-bottom; animation: blink 1s steps(2) infinite; border-radius: 1px; }
@keyframes blink { 50% { opacity: 0; } }
/* ---------------- COMPOSER ---------------- */
.composer-wrap { flex-shrink: 0; padding: 0 28px 18px; }
.composer-inner { max-width: 820px; margin: 0 auto; position: relative; }
.composer { border: 1px solid var(--border-strong); border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow); transition: border-color .18s, box-shadow .18s; }
.composer.focus { border-color: var(--accent-line); box-shadow: var(--shadow), 0 0 0 3px var(--accent-soft); }
.composer-field { width: 100%; resize: none; border: none; outline: none; background: none; color: var(--text); font-size: 14.5px; line-height: 1.55; padding: 14px 16px 6px; max-height: 200px; min-height: 26px; }
.composer-field::placeholder { color: var(--text-dim); }
.composer-bar { display: flex; align-items: center; gap: 6px; padding: 8px 10px 9px; }
.cb-btn { display: flex; align-items: center; gap: 6px; padding: 6px 9px; border-radius: var(--radius-sm); font-size: 12.5px; color: var(--text-muted); transition: background .13s, color .13s; }
.cb-btn:hover { background: var(--surface-2); color: var(--text); }
.cb-spacer { flex: 1; }
.send-btn { width: 32px; height: 32px; border-radius: var(--radius-sm); display: grid; place-items: center; background: var(--accent); color: #fff; transition: filter .13s, transform .08s, opacity .13s; }
.send-btn:hover { filter: brightness(1.1); }
.send-btn:active { transform: scale(.94); }
.send-btn:disabled { opacity: .4; cursor: not-allowed; }
.composer-hint { text-align: center; font-size: 11px; color: var(--text-dim); margin-top: 9px; }
.composer-hint .kbd { font-size: 10px; }
/* empty state */
.empty-state { flex: 1; display: grid; place-items: center; text-align: center; padding: 40px; }
.empty-state .es-icon { width: 56px; height: 56px; border-radius: 16px; margin: 0 auto 18px; display: grid; place-items: center; background: var(--surface); border: 1px solid var(--border); color: var(--accent-2); }
.empty-state h2 { font-size: 19px; font-weight: 600; margin: 0 0 8px; letter-spacing: -0.01em; }
.empty-state p { font-size: 13.5px; color: var(--text-muted); max-width: 380px; margin: 0 auto; line-height: 1.55; }
.suggest-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 24px; max-width: 460px; }
.suggest { text-align: left; padding: 12px 14px; border-radius: var(--radius); background: var(--surface); border: 1px solid var(--border); transition: border-color .15s, transform .08s; }
.suggest:hover { border-color: var(--accent-line); transform: translateY(-1px); }
.suggest .sg-title { font-size: 12.5px; font-weight: 500; margin-bottom: 3px; }
.suggest .sg-desc { font-size: 11.5px; color: var(--text-dim); line-height: 1.4; }
/* toast */
.toast { position: fixed; bottom: 92px; left: 50%; transform: translateX(-50%) translateY(20px); background: var(--surface-2); border: 1px solid var(--border-strong); color: var(--text); padding: 8px 15px; border-radius: 9px; font-size: 12.5px; z-index: 80; opacity: 0; pointer-events: none; transition: opacity .2s, transform .2s; box-shadow: var(--shadow-pop); }
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
.err-text { color: var(--err); }
/* ---------------- POPOVERS (model menu, mention) ---------------- */
.pop { position: absolute; z-index: 70; min-width: 240px; background: var(--bg-elev); border: 1px solid var(--border-strong); border-radius: var(--radius); box-shadow: var(--shadow-pop); padding: 6px; }
.pop-label { font-size: 10.5px; font-weight: 600; letter-spacing: .05em; text-transform: uppercase; color: var(--text-dim); padding: 8px 9px 4px; }
.pop-item { display: flex; align-items: center; gap: 10px; padding: 8px 9px; border-radius: var(--radius-sm); font-size: 13px; cursor: pointer; }
.pop-item:hover, .pop-item.hl { background: var(--surface-2); }
.pop-item.disabled { cursor: not-allowed; opacity: .58; }
.pop-item.disabled:hover { background: transparent; }
.pop-item .pi-main { flex: 1; min-width: 0; }
.pop-item .pi-name { font-weight: 500; display: flex; align-items: center; gap: 7px; }
.pop-item .pi-desc { font-size: 11px; color: var(--text-dim); margin-top: 1px; }
.pop-item .pi-check { color: var(--accent-2); display: grid; place-items: center; }
/* mention autocomplete */
.mention-pop { left: 12px; bottom: calc(100% + 8px); width: 360px; max-height: 320px; overflow-y: auto; }
.mp-row { display: flex; align-items: center; gap: 10px; padding: 7px 9px; border-radius: var(--radius-sm); cursor: pointer; }
.mp-row:hover, .mp-row.hl { background: var(--surface-2); }
.mp-ico { width: 22px; height: 22px; border-radius: 6px; display: grid; place-items: center; background: var(--accent-soft); color: var(--accent-2); flex-shrink: 0; }
.mp-main { flex: 1; min-width: 0; }
.mp-name { font-size: 13px; font-weight: 500; font-family: var(--font-mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.mp-path { font-size: 11px; color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.mp-cls { font-size: 10px; color: var(--text-dim); font-family: var(--font-mono); }
.mp-open { font-size: 9px; font-weight: 600; color: var(--ok); border: 1px solid rgba(78,201,143,.4); border-radius: 4px; padding: 1px 5px; }
.mp-empty { padding: 14px; text-align: center; color: var(--text-dim); font-size: 12.5px; }
/* attachments */
.attach-row { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
.attach-thumb { position: relative; width: 56px; height: 56px; border-radius: var(--radius-sm); overflow: hidden; border: 1px solid var(--border-strong); background: var(--surface-2); }
.attach-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.attach-thumb .at-x { position: absolute; top: 2px; right: 2px; width: 16px; height: 16px; border-radius: 5px; background: rgba(0,0,0,.6); color: #fff; display: grid; place-items: center; }
.attach-thumb .at-x:hover { background: var(--err); }
.msg-images { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
.msg-images img { max-width: 220px; max-height: 180px; border-radius: var(--radius-sm); border: 1px solid var(--border); display: block; }
/* ---------------- SETTINGS / INTEGRATIONS ---------------- */
.modal-backdrop { position: fixed; inset: 0; z-index: 100; display: grid; place-items: center; padding: 28px; background: rgba(0,0,0,.72); }
.modal { width: min(820px, 100%); max-height: min(760px, calc(100vh - 56px)); display: flex; flex-direction: column; overflow: hidden; border: 1px solid var(--border-strong); border-radius: var(--radius-lg); background: var(--bg-elev); box-shadow: var(--shadow-pop); }
.sound-modal { width: min(580px, 100%); }
.translate-modal { width: min(440px, 100%); }
.modal-head, .modal-foot { display: flex; align-items: center; gap: 12px; padding: 16px 18px; border-bottom: 1px solid var(--border); }
.modal-foot { justify-content: flex-end; border-top: 1px solid var(--border); border-bottom: none; }
.modal-title { font-size: 16px; font-weight: 650; }
.modal-sub { margin-top: 3px; font-size: 11.5px; color: var(--text-dim); }
.modal-close { margin-left: auto; width: 30px; height: 30px; border-radius: var(--radius-sm); color: var(--text-muted); font-size: 18px; }
.modal-close:hover { background: var(--surface-2); color: var(--text); }
.settings-body { display: grid; grid-template-columns: 170px minmax(0,1fr); min-height: 0; overflow: hidden; }
.settings-nav { padding: 14px 10px; border-right: 1px solid var(--border); background: var(--bg); }
.settings-nav-item { width: 100%; display: flex; align-items: center; gap: 8px; padding: 9px 10px; border-radius: var(--radius-sm); color: var(--text-muted); font-size: 12.5px; text-align: left; }
.settings-nav-item.active { color: var(--text); background: var(--surface-2); }
.settings-content { padding: 22px; overflow-y: auto; }
.settings-kicker { color: var(--accent-2); text-transform: uppercase; letter-spacing: .08em; font-size: 10px; font-weight: 700; }
.settings-content h2 { margin: 5px 0 5px; font-size: 19px; }
.settings-intro { margin: 0 0 18px; color: var(--text-muted); font-size: 12.5px; line-height: 1.55; }
.integration-card { padding: 15px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); margin-top: 12px; }
.integration-head { display: flex; gap: 12px; align-items: center; margin-bottom: 14px; }
.integration-logo { width: 34px; height: 34px; flex-shrink: 0; display: grid; place-items: center; border-radius: 9px; font-size: 11px; font-weight: 700; }
.integration-logo.eleven { color: #050505; background: #f2f1ea; }
.integration-logo.codex { color: #fff; background: linear-gradient(145deg,#353842,#15171b); border: 1px solid var(--border-strong); }
.integration-logo.textfix { color: #fff; background: linear-gradient(145deg,#745fd5,#9b72ee); }
.integration-meta { flex: 1; min-width: 0; }
.integration-meta h3 { margin: 0 0 3px; font-size: 14px; }
.integration-meta p { margin: 0; color: var(--text-dim); font-size: 11.5px; line-height: 1.45; }
.integration-state { flex-shrink: 0; border: 1px solid var(--border); border-radius: 99px; padding: 3px 8px; color: var(--text-dim); font-size: 10.5px; }
.integration-state.connected { color: var(--ok); border-color: rgba(78,201,143,.35); background: rgba(78,201,143,.08); }
.field { display: flex; flex-direction: column; gap: 6px; margin-top: 11px; color: var(--text-muted); font-size: 11.5px; }
.field input, .field select, .field textarea { width: 100%; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); outline: none; padding: 9px 10px; color: var(--text); background: var(--bg); font-size: 12.5px; resize: vertical; }
.field input:focus, .field select:focus, .field textarea:focus { border-color: var(--accent-line); box-shadow: 0 0 0 3px var(--accent-soft); }
.field small, .integration-note { color: var(--text-dim); font-size: 10.5px; }
.field-grid { display: grid; grid-template-columns: minmax(0,1fr) 150px; gap: 12px; }
.integration-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.primary-btn, .secondary-btn { padding: 8px 12px; border-radius: var(--radius-sm); font-size: 12.5px; font-weight: 600; }
.primary-btn { color: #fff; background: var(--accent); }
.primary-btn:hover { filter: brightness(1.1); }
.primary-btn:disabled { opacity: .45; cursor: wait; }
.secondary-btn { color: var(--text-muted); border: 1px solid var(--border-strong); background: var(--surface); }
.secondary-btn:hover { color: var(--text); border-color: var(--accent-line); }
.sound-modal-body { padding: 18px; }
.sound-options { display: grid; grid-template-columns: 110px 120px minmax(0,1fr) 88px; gap: 9px; margin-top: 15px; }
.sound-option { display: flex; min-width: 0; flex-direction: column; gap: 7px; padding: 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); }
.sound-option-label { color: var(--text-dim); font-size: 10px; font-weight: 650; letter-spacing: .05em; text-transform: uppercase; }
.sound-option select { min-width: 0; border: none; outline: none; color: var(--text); background: var(--surface); font-size: 12px; }
.toggle-btn { display: flex; align-items: center; gap: 7px; width: fit-content; color: var(--text-muted); font-size: 12px; }
.toggle-track { width: 29px; height: 16px; padding: 2px; border-radius: 99px; background: var(--surface-3); transition: background .16s; }
.toggle-knob { display: block; width: 12px; height: 12px; border-radius: 50%; background: var(--text-muted); transition: transform .16s, background .16s; }
.toggle-btn.on .toggle-track { background: var(--accent); }
.toggle-btn.on .toggle-knob { transform: translateX(13px); background: #fff; }
.range-row { display: flex; align-items: center; gap: 8px; min-width: 0; }
.range-row input { min-width: 0; flex: 1; accent-color: var(--accent-2); }
.range-row strong, .toggle-btn strong { color: var(--text); font-size: 12px; }
@media (max-width: 760px) { .sound-options { grid-template-columns: 1fr 1fr; } }
/* ---------------- GENERATED AUDIO ---------------- */
.audio-set { margin-top: 10px; display: grid; gap: 9px; }
.audio-prompt { margin-bottom: 10px; color: var(--text-muted); font-size: 12.5px; }
.audio-card { padding: 11px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); }
.audio-card-head { display: flex; align-items: center; gap: 9px; margin-bottom: 8px; }
.audio-badge { width: 25px; height: 25px; display: grid; place-items: center; border-radius: 7px; color: var(--accent-2); background: var(--accent-soft); }
.audio-name { flex: 1; min-width: 0; font-family: var(--font-mono); font-size: 11.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.audio-meta { color: var(--text-dim); font-size: 10.5px; }
.forge-player { display: flex; align-items: center; gap: 9px; min-width: 0; padding: 8px 9px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg); }
.forge-player audio { display: none; }
.player-play { width: 27px; height: 27px; flex-shrink: 0; display: grid; place-items: center; border-radius: 50%; color: #fff; background: var(--accent); transition: filter .13s, transform .08s; }
.player-play:hover { filter: brightness(1.15); }
.player-play:active { transform: scale(.93); }
.player-time { flex-shrink: 0; min-width: 30px; color: var(--text-dim); font-family: var(--font-mono); font-size: 10px; text-align: center; }
.player-progress, .player-volume { height: 4px; accent-color: var(--accent-2); cursor: pointer; }
.player-progress { min-width: 70px; flex: 1; }
.player-volume { width: 62px; flex-shrink: 0; }
.player-volume-icon { display: grid; place-items: center; flex-shrink: 0; color: var(--text-dim); }
.player-progress::-webkit-slider-runnable-track, .player-volume::-webkit-slider-runnable-track { height: 4px; border-radius: 99px; background: var(--surface-3); }
.player-progress::-webkit-slider-thumb, .player-volume::-webkit-slider-thumb { width: 11px; height: 11px; margin-top: -3.5px; border-radius: 50%; appearance: none; background: var(--accent-2); box-shadow: 0 0 0 3px var(--accent-soft); }
.import-btn { flex-shrink: 0; padding: 6px 9px; border-radius: 6px; color: var(--accent-2); border: 1px solid var(--accent-line); font-size: 11px; font-weight: 650; }
.import-btn:hover { background: var(--accent-soft); }
.import-btn:disabled { color: var(--text-dim); border-color: var(--border); cursor: wait; }
.import-btn.done { color: var(--ok); border-color: rgba(78,201,143,.35); }
.sound-working { display: flex; align-items: center; gap: 9px; color: var(--text-muted); font-size: 12.5px; }