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:
699
agent-gateway/public/app.js
Normal file
699
agent-gateway/public/app.js
Normal 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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();
|
||||
})();
|
||||
194
agent-gateway/public/index.html
Normal file
194
agent-gateway/public/index.html
Normal 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"></></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>
|
||||
369
agent-gateway/public/styles.css
Normal file
369
agent-gateway/public/styles.css
Normal 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; }
|
||||
Reference in New Issue
Block a user