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>
902 lines
37 KiB
JavaScript
902 lines
37 KiB
JavaScript
// In-engine agent gateway.
|
|
//
|
|
// What this is:
|
|
// A tiny local HTTP server that (1) serves the chat UI loaded by the
|
|
// UE WebBrowser widget and (2) runs a Claude agent via the Claude Agent SDK,
|
|
// wired to the project's ue-blueprint MCP server so the in-engine chat has
|
|
// the exact same UE tools Claude Code uses (edit BPs, spawn nodes, build
|
|
// C++, screenshot UMG, ...).
|
|
//
|
|
// Transport to the browser: POST /api/chat with a JSON body, response is an
|
|
// SSE stream of agent events (text deltas, tool start/end, done). The browser
|
|
// keeps a sessionId returned on the first turn and replays it to continue the
|
|
// conversation (SDK `resume`).
|
|
//
|
|
// Auth: uses whatever the Agent SDK finds locally (the same subscription
|
|
// login Claude Code uses). No API key is read or required here.
|
|
|
|
import { createServer } from "node:http";
|
|
import { readFileSync, existsSync } from "node:fs";
|
|
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
import { fileURLToPath } from "node:url";
|
|
import { randomUUID } from "node:crypto";
|
|
import path from "node:path";
|
|
import os from "node:os";
|
|
|
|
import { spawn } from "node:child_process";
|
|
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
import { executePythonInUE } from "../src/ueBridge.js";
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const PUBLIC_DIR = path.join(__dirname, "public");
|
|
const INTEGRATIONS_FILE = path.join(__dirname, "integrations.json");
|
|
const GENERATED_AUDIO_DIR = path.join(__dirname, ".generated-audio");
|
|
const AUDIO_CACHE = new Map();
|
|
|
|
// Optional local auth file (gitignored). Lets you keep the agent's credentials
|
|
// out of the global environment. One KEY=VALUE per line. Supported keys:
|
|
// CLAUDE_CODE_OAUTH_TOKEN (from `claude setup-token` — subscription auth)
|
|
// ANTHROPIC_API_KEY (pay-as-you-go API key)
|
|
function loadAuthEnv() {
|
|
const file = path.join(__dirname, "auth.env");
|
|
if (!existsSync(file)) return;
|
|
for (const line of readFileSync(file, "utf8").split(/\r?\n/)) {
|
|
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
|
|
if (!m) continue;
|
|
const val = m[2].replace(/^["']|["']$/g, "");
|
|
if (val && !process.env[m[1]]) process.env[m[1]] = val;
|
|
}
|
|
}
|
|
loadAuthEnv();
|
|
const HAS_AUTH = !!(process.env.CLAUDE_CODE_OAUTH_TOKEN || process.env.ANTHROPIC_API_KEY);
|
|
|
|
const PORT = Number(process.env.MCP_AGENT_PORT || 8765);
|
|
const HOST = process.env.MCP_AGENT_HOST || "127.0.0.1";
|
|
const PROJECT_DIR = (process.env.UE_PROJECT_DIR || path.resolve(__dirname, "..", "..", "..")).replace(/\\/g, "/");
|
|
const MCP_SERVER_ENTRY = path.resolve(__dirname, "..", "src", "server.js").replace(/\\/g, "/");
|
|
const MODEL = process.env.MCP_AGENT_MODEL || "claude-opus-4-8";
|
|
|
|
function defaultIntegrations() {
|
|
return {
|
|
elevenlabs: {
|
|
apiKey: "",
|
|
importPath: "/Game/Audio/Generated",
|
|
variationCount: 3,
|
|
loop: false,
|
|
durationSeconds: null,
|
|
promptInfluence: 0.3,
|
|
modelId: "eleven_text_to_sound_v2",
|
|
},
|
|
textTools: {
|
|
improvePrompt: "Fix spelling, grammar, punctuation, and clarity while preserving the original meaning, tone, formatting, and language. Return only the improved text.",
|
|
translateLanguages: ["English", "Russian", "German", "French", "Spanish", "Japanese", "Chinese"],
|
|
},
|
|
};
|
|
}
|
|
|
|
function loadIntegrations() {
|
|
try {
|
|
const stored = JSON.parse(readFileSync(INTEGRATIONS_FILE, "utf8"));
|
|
return {
|
|
elevenlabs: {
|
|
...defaultIntegrations().elevenlabs,
|
|
...(stored.elevenlabs || {}),
|
|
},
|
|
textTools: {
|
|
...defaultIntegrations().textTools,
|
|
...(stored.textTools || {}),
|
|
},
|
|
};
|
|
} catch {
|
|
return defaultIntegrations();
|
|
}
|
|
}
|
|
|
|
let INTEGRATIONS = loadIntegrations();
|
|
|
|
async function saveIntegrations() {
|
|
await writeFile(INTEGRATIONS_FILE, JSON.stringify(INTEGRATIONS, null, 2) + "\n", "utf8");
|
|
}
|
|
|
|
function codexAuthStatus() {
|
|
return {
|
|
connected: existsSync(path.join(os.homedir(), ".codex", "auth.json")),
|
|
method: "OAuth",
|
|
};
|
|
}
|
|
|
|
function publicSettings() {
|
|
const elevenlabs = INTEGRATIONS.elevenlabs;
|
|
return {
|
|
elevenlabs: {
|
|
configured: !!elevenlabs.apiKey,
|
|
apiKeyMasked: elevenlabs.apiKey ? `${elevenlabs.apiKey.slice(0, 5)}...${elevenlabs.apiKey.slice(-4)}` : "",
|
|
importPath: elevenlabs.importPath,
|
|
variationCount: elevenlabs.variationCount,
|
|
loop: elevenlabs.loop,
|
|
durationSeconds: elevenlabs.durationSeconds,
|
|
promptInfluence: elevenlabs.promptInfluence,
|
|
modelId: elevenlabs.modelId,
|
|
},
|
|
codex: codexAuthStatus(),
|
|
textTools: {
|
|
improvePrompt: INTEGRATIONS.textTools.improvePrompt,
|
|
translateLanguages: INTEGRATIONS.textTools.translateLanguages,
|
|
},
|
|
};
|
|
}
|
|
|
|
function json(res, status, payload) {
|
|
res.writeHead(status, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify(payload));
|
|
}
|
|
|
|
function safeAudioName(prompt, index) {
|
|
const base = prompt.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 42) || "generated_sound";
|
|
return `${base}_${String(index + 1).padStart(2, "0")}`;
|
|
}
|
|
|
|
function pcm16MonoToWav(pcm, sampleRate = 48000) {
|
|
const wav = Buffer.alloc(44 + pcm.length);
|
|
wav.write("RIFF", 0);
|
|
wav.writeUInt32LE(36 + pcm.length, 4);
|
|
wav.write("WAVE", 8);
|
|
wav.write("fmt ", 12);
|
|
wav.writeUInt32LE(16, 16);
|
|
wav.writeUInt16LE(1, 20); // PCM
|
|
wav.writeUInt16LE(1, 22); // mono
|
|
wav.writeUInt32LE(sampleRate, 24);
|
|
wav.writeUInt32LE(sampleRate * 2, 28);
|
|
wav.writeUInt16LE(2, 32);
|
|
wav.writeUInt16LE(16, 34);
|
|
wav.write("data", 36);
|
|
wav.writeUInt32LE(pcm.length, 40);
|
|
pcm.copy(wav, 44);
|
|
return wav;
|
|
}
|
|
|
|
async function generateSoundEffect(prompt, index, options) {
|
|
const id = randomUUID();
|
|
const name = safeAudioName(prompt, index);
|
|
const url = "https://api.elevenlabs.io/v1/sound-generation?output_format=pcm_48000";
|
|
const response = await fetch(url, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"xi-api-key": INTEGRATIONS.elevenlabs.apiKey,
|
|
},
|
|
body: JSON.stringify({
|
|
text: prompt,
|
|
model_id: options.modelId,
|
|
loop: options.loop,
|
|
duration_seconds: options.durationSeconds,
|
|
prompt_influence: options.promptInfluence,
|
|
}),
|
|
});
|
|
if (!response.ok) {
|
|
const detail = (await response.text()).slice(0, 500);
|
|
throw new Error(`ElevenLabs HTTP ${response.status}: ${detail}`);
|
|
}
|
|
const bytes = pcm16MonoToWav(Buffer.from(await response.arrayBuffer()));
|
|
await mkdir(GENERATED_AUDIO_DIR, { recursive: true });
|
|
const filePath = path.join(GENERATED_AUDIO_DIR, `${id}.wav`);
|
|
await writeFile(filePath, bytes);
|
|
const item = { id, name, prompt, filePath, createdAt: Date.now(), bytes: bytes.length };
|
|
AUDIO_CACHE.set(id, item);
|
|
return { id, name, prompt, bytes: bytes.length, audioUrl: `/api/elevenlabs/audio/${id}` };
|
|
}
|
|
|
|
async function importGeneratedSound(id) {
|
|
const item = AUDIO_CACHE.get(id);
|
|
if (!item || !existsSync(item.filePath)) throw new Error("Generated sound has expired. Generate it again.");
|
|
const destination = INTEGRATIONS.elevenlabs.importPath;
|
|
if (!destination.startsWith("/Game/")) throw new Error("Import path must start with /Game/");
|
|
const resultFile = path.join(PROJECT_DIR, "Saved", "MCP", `forge_audio_import_${id}.json`).replace(/\\/g, "/");
|
|
const sourceFile = item.filePath.replace(/\\/g, "/");
|
|
const assetName = item.name.replace(/[^A-Za-z0-9_]/g, "_");
|
|
const py = [
|
|
"import unreal, json, os",
|
|
`task=unreal.AssetImportTask()`,
|
|
`task.set_editor_property('filename', ${JSON.stringify(sourceFile)})`,
|
|
`task.set_editor_property('destination_path', ${JSON.stringify(destination)})`,
|
|
`task.set_editor_property('destination_name', ${JSON.stringify(assetName)})`,
|
|
"task.set_editor_property('automated', True)",
|
|
"task.set_editor_property('save', True)",
|
|
"task.set_editor_property('replace_existing', False)",
|
|
"unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])",
|
|
"paths=[str(x) for x in task.get_editor_property('imported_object_paths')]",
|
|
`out={'ok':bool(paths),'paths':paths,'error':None if paths else 'UE did not import the WAV file'}`,
|
|
`os.makedirs(os.path.dirname(r'${resultFile}'), exist_ok=True)`,
|
|
`open(r'${resultFile}','w',encoding='utf-8').write(json.dumps(out))`,
|
|
].join("\n");
|
|
const sent = await executePythonInUE(py, { timeoutMs: 45000 });
|
|
if (!sent.ok) throw new Error(sent.error || "Cannot reach Unreal Editor");
|
|
const result = JSON.parse(await readFile(resultFile, "utf8"));
|
|
if (!result.ok) throw new Error(result.error || "Import failed");
|
|
return result;
|
|
}
|
|
|
|
async function runTextTool(instruction, text) {
|
|
if (!HAS_AUTH) throw new Error("Forge agent auth is not configured.");
|
|
const prompt = [
|
|
"Transform the text exactly as requested.",
|
|
"Do not answer the text, discuss it, add notes, or wrap the result in quotes or markdown fences.",
|
|
"Return only the transformed text.",
|
|
"",
|
|
`Instruction: ${instruction}`,
|
|
"",
|
|
"Text:",
|
|
text,
|
|
].join("\n");
|
|
let result = "";
|
|
const options = {
|
|
...agentOptions(MODEL),
|
|
maxTurns: 1,
|
|
allowedTools: [],
|
|
mcpServers: {},
|
|
systemPrompt: "You are a precise text editing utility. Return only the transformed text.",
|
|
};
|
|
for await (const message of query({ prompt, options })) {
|
|
if (message.type === "result") result = typeof message.result === "string" ? message.result.trim() : result;
|
|
}
|
|
if (!result) throw new Error("Text tool returned an empty result.");
|
|
return result;
|
|
}
|
|
|
|
const SYSTEM_PROMPT = `You are the in-engine assistant for the current Unreal Engine project.
|
|
You are embedded inside the Unreal editor via a WebBrowser widget. The developer talks to you here while the editor is open.
|
|
|
|
You have full access to the project's ue-blueprint MCP tools (prefixed mcp__ue-blueprint__) to inspect and modify the project live: edit Blueprint graphs, spawn/connect nodes, compile, build C++ via live coding, author UMG and screenshot it, run PCG/Niagara/material ops, run console commands, analyze Insights traces, and more. Prefer these tools over guessing.
|
|
|
|
Conventions:
|
|
- Always confirm UE is reachable with mcp__ue-blueprint__ping_ue before tool-heavy work in a fresh session.
|
|
- For any visual/UMG work, follow the project's UI workflow and use the screenshot vision loop.
|
|
- Reference assets with their full /Game/... or /Script/... path, and Blueprint nodes by name — the chat UI turns those into clickable chips, so be precise.
|
|
- Be concise. The chat panel is narrow. Lead with the answer, then detail.
|
|
|
|
The project lives at ${PROJECT_DIR}.`;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SSE helpers
|
|
// ---------------------------------------------------------------------------
|
|
function sseHead(res) {
|
|
res.writeHead(200, {
|
|
"Content-Type": "text/event-stream",
|
|
"Cache-Control": "no-cache, no-transform",
|
|
Connection: "keep-alive",
|
|
"X-Accel-Buffering": "no",
|
|
});
|
|
}
|
|
|
|
function sseSend(res, event, data) {
|
|
if (event !== "text") console.log(`[sse] -> ${event}`);
|
|
const ok = res.write(`event: ${event}\n`);
|
|
res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
if (!ok) console.log(`[sse] backpressure on ${event}`);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sessions: one long-lived Agent SDK query per chat, fed via streaming input.
|
|
// The conversation lives in memory (no `resume` / disk reload). This avoids the
|
|
// "tool_use ids must be unique" 400 that resume+tools produced when the saved
|
|
// transcript got a duplicated tool_use block.
|
|
// ---------------------------------------------------------------------------
|
|
// Models offered in the chat's model switcher. id must be a valid Anthropic id.
|
|
const MODELS = [
|
|
{ id: "claude-opus-4-8", name: "Claude Opus 4.8", tag: "самый умный" },
|
|
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", tag: "баланс" },
|
|
{ id: "claude-haiku-4-5-20251001", name: "Claude Haiku 4.5", tag: "быстрый" },
|
|
];
|
|
const MODEL_IDS = new Set(MODELS.map((m) => m.id));
|
|
|
|
function modelsForClient() {
|
|
const models = [...MODELS];
|
|
if (codexAuthStatus().connected) {
|
|
models.push({ id: "codex-oauth", name: "Codex", tag: "OAuth connected · runtime unavailable", disabled: true });
|
|
}
|
|
return models;
|
|
}
|
|
|
|
function agentOptions(model) {
|
|
return {
|
|
model: MODEL_IDS.has(model) ? model : MODEL,
|
|
cwd: PROJECT_DIR,
|
|
permissionMode: "bypassPermissions", // no interactive terminal here; full-access agent per project choice
|
|
allowDangerouslySkipPermissions: true, // required alongside bypassPermissions
|
|
includePartialMessages: true, // live token streaming (safe on SDK 0.3+)
|
|
settingSources: ["project"], // load the repo CLAUDE.md so the agent knows the conventions
|
|
mcpServers: {
|
|
"ue-blueprint": {
|
|
type: "stdio",
|
|
command: "node",
|
|
args: [MCP_SERVER_ENTRY],
|
|
env: { ...process.env, UE_PROJECT_DIR: PROJECT_DIR },
|
|
},
|
|
},
|
|
systemPrompt: { type: "preset", preset: "claude_code", append: SYSTEM_PROMPT },
|
|
stderr: (data) => console.error("[sdk-stderr]", data),
|
|
};
|
|
}
|
|
|
|
// Tools available to the agent — captured from the first init we ever see, so
|
|
// the sidebar can show a count before the user's first message.
|
|
let CACHED_TOOLS = [];
|
|
|
|
// Image attachments: uploaded out-of-band (POST /api/upload), then referenced by
|
|
// id in the EventSource GET (which can't carry a base64 body).
|
|
const ATTACH = new Map();
|
|
function putAttach(dataUrl) {
|
|
const m = /^data:([^;]+);base64,(.+)$/s.exec(dataUrl || "");
|
|
if (!m) return null;
|
|
const id = randomUUID().slice(0, 8);
|
|
ATTACH.set(id, { media_type: m[1], data: m[2], t: Date.now() });
|
|
return id;
|
|
}
|
|
function takeAttach(ids) {
|
|
const out = [];
|
|
for (const id of ids) {
|
|
const a = ATTACH.get(id);
|
|
if (a) { out.push({ media_type: a.media_type, data: a.data }); ATTACH.delete(id); }
|
|
}
|
|
return out;
|
|
}
|
|
setInterval(() => { const now = Date.now(); for (const [k, v] of ATTACH) if (now - v.t > 300000) ATTACH.delete(k); }, 60000).unref?.();
|
|
|
|
const sessions = new Map();
|
|
|
|
class Session {
|
|
constructor(model) {
|
|
this.id = randomUUID();
|
|
this.model = MODEL_IDS.has(model) ? model : MODEL;
|
|
this.pending = []; // queued { text, images? }
|
|
this.wake = null; // resolver to wake the input generator
|
|
this.res = null; // SSE response for the in-flight turn
|
|
this.busy = false; // a turn is running in the SDK (independent of the socket)
|
|
this.closed = false;
|
|
this.resetTurn();
|
|
this.consume();
|
|
}
|
|
|
|
resetTurn() {
|
|
this.sawText = false;
|
|
this.textLen = 0;
|
|
this.assistantText = "";
|
|
}
|
|
|
|
// Streaming input: yields each queued user message, then parks until the next.
|
|
async *input() {
|
|
while (!this.closed) {
|
|
if (this.pending.length === 0) {
|
|
await new Promise((r) => { this.wake = r; });
|
|
if (this.closed) break;
|
|
}
|
|
const item = this.pending.shift();
|
|
let content;
|
|
if (item.images && item.images.length) {
|
|
content = [{ type: "text", text: item.text || "" }];
|
|
for (const img of item.images) content.push({ type: "image", source: { type: "base64", media_type: img.media_type, data: img.data } });
|
|
} else {
|
|
content = item.text;
|
|
}
|
|
yield { type: "user", message: { role: "user", content }, parent_tool_use_id: null, session_id: this.id };
|
|
}
|
|
}
|
|
|
|
push(item) {
|
|
this.pending.push(item);
|
|
if (this.wake) { const w = this.wake; this.wake = null; w(); }
|
|
}
|
|
|
|
beginTurn(res, message, images) {
|
|
this.res = res;
|
|
this.busy = true;
|
|
this.resetTurn();
|
|
sseSend(res, "init", { sessionId: this.id, model: this.model, tools: CACHED_TOOLS });
|
|
this.push({ text: message, images });
|
|
}
|
|
|
|
endTurn() {
|
|
this.busy = false;
|
|
if (this.res) { try { this.res.end(); } catch { /* ignore */ } this.res = null; }
|
|
}
|
|
|
|
async consume() {
|
|
try {
|
|
for await (const msg of query({ prompt: this.input(), options: agentOptions(this.model) })) {
|
|
if (msg.type === "system" && msg.subtype === "init" && Array.isArray(msg.tools) && msg.tools.length) CACHED_TOOLS = msg.tools;
|
|
this.route(msg);
|
|
}
|
|
} catch (err) {
|
|
console.error("[session]", this.id.slice(0, 8), "error:", err?.message || err);
|
|
if (this.res) sseSend(this.res, "error", { message: String(err?.message || err) });
|
|
this.endTurn();
|
|
}
|
|
}
|
|
|
|
route(msg) {
|
|
const res = this.res;
|
|
switch (msg.type) {
|
|
case "stream_event": {
|
|
const ev = msg.event;
|
|
if (ev?.type === "content_block_delta" && ev.delta?.type === "text_delta") {
|
|
this.sawText = true;
|
|
this.textLen += (ev.delta.text || "").length;
|
|
if (res) sseSend(res, "text", { delta: ev.delta.text });
|
|
} else if (ev?.type === "content_block_delta" && ev.delta?.type === "thinking_delta") {
|
|
if (res) sseSend(res, "thinking", { delta: ev.delta.thinking });
|
|
}
|
|
break;
|
|
}
|
|
case "assistant": {
|
|
for (const b of (msg.message?.content || [])) {
|
|
if (b.type === "tool_use") {
|
|
if (res) sseSend(res, "tool_start", { id: b.id, name: b.name, input: b.input });
|
|
} else if (b.type === "text" && typeof b.text === "string") {
|
|
this.assistantText += b.text;
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
case "user": {
|
|
for (const b of (msg.message?.content || [])) {
|
|
if (b.type === "tool_result") {
|
|
if (res) sseSend(res, "tool_end", {
|
|
id: b.tool_use_id,
|
|
isError: !!b.is_error,
|
|
content: normalizeToolResult(b.content),
|
|
});
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
case "result": {
|
|
const resultText = (typeof msg.result === "string" ? msg.result : "") || this.assistantText;
|
|
console.log(`[session ${this.id.slice(0, 8)}] result: sawText=${this.sawText} streamed=${this.textLen} resultChars=${resultText.length}`);
|
|
if (res) {
|
|
if (msg.is_error || msg.subtype === "error_during_execution") {
|
|
sseSend(res, "error", { message: resultText || msg.subtype || "agent error" });
|
|
} else if (!this.sawText && resultText) {
|
|
// No live deltas (tool-only turn) — emit the full final text.
|
|
sseSend(res, "text", { delta: resultText });
|
|
}
|
|
sseSend(res, "done", {
|
|
sessionId: this.id,
|
|
subtype: msg.subtype,
|
|
isError: !!msg.is_error,
|
|
durationMs: msg.duration_ms,
|
|
costUsd: msg.total_cost_usd,
|
|
});
|
|
}
|
|
this.endTurn();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Bind an SSE response to a (new or existing) session and run one turn.
|
|
function handleTurn(res, { message, sessionId, model, images }) {
|
|
let session = sessionId ? sessions.get(sessionId) : null;
|
|
if (!session) {
|
|
session = new Session(model);
|
|
sessions.set(session.id, session);
|
|
}
|
|
sseHead(res);
|
|
if (session.busy) {
|
|
// A turn is still running in the SDK (client should serialize; this also
|
|
// guards against a dropped socket whose turn hasn't finished yet).
|
|
sseSend(res, "init", { sessionId: session.id, model: session.model, tools: CACHED_TOOLS });
|
|
sseSend(res, "error", { message: "агент ещё занят предыдущим запросом — подожди" });
|
|
sseSend(res, "done", { sessionId: session.id, subtype: "busy" });
|
|
res.end();
|
|
return;
|
|
}
|
|
res.on("close", () => { if (session.res === res) session.res = null; });
|
|
session.beginTurn(res, message, images);
|
|
}
|
|
|
|
// Exact ue-blueprint tool count. The SDK's init reports the MCP server as
|
|
// "pending" (tools connect after init), so we ask the MCP server directly over
|
|
// its stdio JSON-RPC: initialize → tools/list. Cheap, runs once at startup.
|
|
let UE_TOOL_COUNT = 0;
|
|
function countMcpTools() {
|
|
return new Promise((resolve) => {
|
|
let done = false;
|
|
const child = spawn("node", [MCP_SERVER_ENTRY], { env: { ...process.env, UE_PROJECT_DIR: PROJECT_DIR } });
|
|
const finish = (n) => { if (done) return; done = true; try { child.kill(); } catch { /* ignore */ } resolve(n); };
|
|
const send = (o) => { try { child.stdin.write(JSON.stringify(o) + "\n"); } catch { /* ignore */ } };
|
|
let buf = "";
|
|
child.stdout.on("data", (d) => {
|
|
buf += d.toString();
|
|
let idx;
|
|
while ((idx = buf.indexOf("\n")) >= 0) {
|
|
const line = buf.slice(0, idx); buf = buf.slice(idx + 1);
|
|
if (!line.trim()) continue;
|
|
let msg; try { msg = JSON.parse(line); } catch { continue; }
|
|
if (msg.id === 1 && msg.result) {
|
|
send({ jsonrpc: "2.0", method: "notifications/initialized" });
|
|
send({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} });
|
|
}
|
|
if (msg.id === 2 && msg.result && Array.isArray(msg.result.tools)) finish(msg.result.tools.length);
|
|
}
|
|
});
|
|
child.on("error", () => finish(0));
|
|
send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "mcp-agent-gateway", version: "1" } } });
|
|
setTimeout(() => finish(0), 8000);
|
|
});
|
|
}
|
|
|
|
// Grab the agent's tool list once at startup (init arrives before any model
|
|
// call), so the sidebar count is populated before the first chat.
|
|
async function primeTools() {
|
|
if (!HAS_AUTH || CACHED_TOOLS.length) return;
|
|
try {
|
|
const q = query({ prompt: "ready", options: agentOptions() });
|
|
for await (const m of q) {
|
|
if (m.type === "system" && m.subtype === "init") { CACHED_TOOLS = m.tools || []; break; }
|
|
}
|
|
if (typeof q.interrupt === "function") { try { await q.interrupt(); } catch { /* ignore */ } }
|
|
console.log(`[primeTools] cached ${CACHED_TOOLS.length} tools`);
|
|
} catch (e) { console.error("[primeTools]", e?.message || e); }
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Asset list (for @mention autocomplete). Cached; refreshed on demand.
|
|
// Dumps the AssetRegistry + open editors to a JSON file UE can write and we read.
|
|
// ---------------------------------------------------------------------------
|
|
let ASSET_CACHE = { at: 0, list: [] };
|
|
const ASSET_FILE = path.join(PROJECT_DIR, "Saved", "MCP", "agent_assets.json").replace(/\\/g, "/");
|
|
async function refreshAssets() {
|
|
const py = [
|
|
"import unreal, json, os",
|
|
"out=[]; seen=set(); open_paths=set()",
|
|
"try:",
|
|
" sub=unreal.get_editor_subsystem(unreal.AssetEditorSubsystem)",
|
|
" for a in sub.get_all_edited_assets():",
|
|
" open_paths.add(a.get_path_name().split('.')[0])",
|
|
"except Exception: pass",
|
|
"ar=unreal.AssetRegistryHelpers.get_asset_registry()",
|
|
"f=unreal.ARFilter(package_paths=['/Game'], recursive_paths=True)",
|
|
"for ad in ar.get_assets(f):",
|
|
" p=str(ad.package_name)",
|
|
" if p in seen: continue",
|
|
" seen.add(p)",
|
|
" try: cls=str(ad.asset_class_path.asset_name)",
|
|
" except Exception: cls=str(getattr(ad,'asset_class',''))",
|
|
" out.append({'name':str(ad.asset_name),'path':p,'class':cls,'open':p in open_paths})",
|
|
`os.makedirs(os.path.dirname(r'${ASSET_FILE}'), exist_ok=True)`,
|
|
`open(r'${ASSET_FILE}','w',encoding='utf-8').write(json.dumps(out))`,
|
|
].join("\n");
|
|
await executePythonInUE(py, { timeoutMs: 25000 });
|
|
const raw = await readFile(ASSET_FILE, "utf8");
|
|
ASSET_CACHE = { at: Date.now(), list: JSON.parse(raw) };
|
|
}
|
|
|
|
function readBody(req) {
|
|
return new Promise((resolve) => {
|
|
let b = ""; req.on("data", (c) => (b += c)); req.on("end", () => { try { resolve(JSON.parse(b || "{}")); } catch { resolve({}); } });
|
|
});
|
|
}
|
|
|
|
function normalizeToolResult(content) {
|
|
if (typeof content === "string") return content;
|
|
if (Array.isArray(content)) {
|
|
return content
|
|
.map((c) => (c?.type === "text" ? c.text : c?.type ? `[${c.type}]` : String(c)))
|
|
.join("\n");
|
|
}
|
|
return content == null ? "" : String(content);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Static file serving
|
|
// ---------------------------------------------------------------------------
|
|
const MIME = {
|
|
".html": "text/html; charset=utf-8",
|
|
".css": "text/css; charset=utf-8",
|
|
".js": "text/javascript; charset=utf-8",
|
|
".svg": "image/svg+xml",
|
|
".png": "image/png",
|
|
".woff2": "font/woff2",
|
|
};
|
|
|
|
async function serveStatic(req, res) {
|
|
let rel = decodeURIComponent(req.url.split("?")[0]);
|
|
if (rel === "/") rel = "/index.html";
|
|
const filePath = path.join(PUBLIC_DIR, path.normalize(rel));
|
|
if (!filePath.startsWith(PUBLIC_DIR)) {
|
|
res.writeHead(403).end("forbidden");
|
|
return;
|
|
}
|
|
try {
|
|
const data = await readFile(filePath);
|
|
const ext = path.extname(filePath).toLowerCase();
|
|
res.writeHead(200, { "Content-Type": MIME[ext] || "application/octet-stream" });
|
|
res.end(data);
|
|
} catch {
|
|
res.writeHead(404).end("not found");
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// HTTP server
|
|
// ---------------------------------------------------------------------------
|
|
const server = createServer(async (req, res) => {
|
|
// CORS — WebBrowser widget origins can be quirky; allow all for localhost dev.
|
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
if (req.method === "OPTIONS") {
|
|
res.writeHead(204).end();
|
|
return;
|
|
}
|
|
|
|
if (req.method === "POST" && req.url === "/api/chat") {
|
|
const payload = await readBody(req);
|
|
const message = (payload.message || "").trim();
|
|
const images = [];
|
|
if (Array.isArray(payload.images)) for (const d of payload.images) { const m = /^data:([^;]+);base64,(.+)$/s.exec(d || ""); if (m) images.push({ media_type: m[1], data: m[2] }); }
|
|
if (!message && !images.length) { res.writeHead(400, { "Content-Type": "application/json" }).end('{"error":"empty message"}'); return; }
|
|
handleTurn(res, { message, sessionId: payload.sessionId || null, model: payload.model || null, images });
|
|
return;
|
|
}
|
|
|
|
// Upload an image attachment (base64 data URL) → returns a short id the
|
|
// EventSource GET references via &attach=, since GET can't carry the body.
|
|
if (req.method === "POST" && req.url === "/api/upload") {
|
|
const payload = await readBody(req);
|
|
const id = putAttach(payload.dataUrl);
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify(id ? { ok: true, id } : { ok: false, error: "bad data url" }));
|
|
return;
|
|
}
|
|
|
|
// EventSource transport (GET). UE's CEF WebBrowser does not consume a streamed
|
|
// fetch() body, but it reads EventSource reliably. Message rides in the query.
|
|
if (req.method === "GET" && req.url.startsWith("/api/chat-stream")) {
|
|
const u = new URL(req.url, `http://${req.headers.host}`);
|
|
const message = (u.searchParams.get("message") || "").trim();
|
|
const sessionId = u.searchParams.get("sessionId") || null;
|
|
const model = u.searchParams.get("model") || null;
|
|
const images = takeAttach((u.searchParams.get("attach") || "").split(",").filter(Boolean));
|
|
if (!message && !images.length) {
|
|
res.writeHead(400, { "Content-Type": "application/json" }).end('{"error":"empty message"}');
|
|
return;
|
|
}
|
|
handleTurn(res, { message, sessionId, model, images });
|
|
return;
|
|
}
|
|
|
|
// Asset list for @mention autocomplete (cached ~30s).
|
|
if (req.method === "GET" && req.url.startsWith("/api/assets")) {
|
|
const u = new URL(req.url, `http://${req.headers.host}`);
|
|
const qq = (u.searchParams.get("q") || "").toLowerCase();
|
|
try { if (Date.now() - ASSET_CACHE.at > 30000) await refreshAssets(); }
|
|
catch (e) { console.error("[assets]", e?.message || e); }
|
|
let list = ASSET_CACHE.list || [];
|
|
if (qq) list = list.filter((a) => a.name.toLowerCase().includes(qq) || a.path.toLowerCase().includes(qq));
|
|
list = list.slice().sort((a, b) => (b.open ? 1 : 0) - (a.open ? 1 : 0)).slice(0, 40);
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ assets: list, total: (ASSET_CACHE.list || []).length }));
|
|
return;
|
|
}
|
|
|
|
if (req.method === "POST" && req.url === "/api/focus") {
|
|
let body = "";
|
|
req.on("data", (c) => (body += c));
|
|
req.on("end", async () => {
|
|
let payload = {};
|
|
try { payload = JSON.parse(body || "{}"); } catch { /* ignore */ }
|
|
const assetPath = (payload.path || "").trim();
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
if (!assetPath.startsWith("/Game/")) {
|
|
res.end(JSON.stringify({ ok: false, error: "not a /Game asset path" }));
|
|
return;
|
|
}
|
|
const py = `import unreal\nunreal.EditorAssetLibrary.sync_browser_to_objects([${JSON.stringify(assetPath)}])`;
|
|
const out = await executePythonInUE(py).catch((e) => ({ ok: false, error: String(e) }));
|
|
res.end(JSON.stringify(out));
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (req.method === "GET" && req.url === "/api/settings") {
|
|
json(res, 200, publicSettings());
|
|
return;
|
|
}
|
|
|
|
if (req.method === "POST" && req.url === "/api/settings") {
|
|
const payload = await readBody(req);
|
|
const current = INTEGRATIONS.elevenlabs;
|
|
const currentTextTools = INTEGRATIONS.textTools;
|
|
const importPath = String(payload.elevenlabs?.importPath || current.importPath).trim().replace(/\/+$/, "");
|
|
const variationCount = Number(payload.elevenlabs?.variationCount || current.variationCount);
|
|
if (!importPath.startsWith("/Game/")) {
|
|
json(res, 400, { ok: false, error: "ElevenLabs import path must start with /Game/" });
|
|
return;
|
|
}
|
|
if (!Number.isInteger(variationCount) || variationCount < 1 || variationCount > 4) {
|
|
json(res, 400, { ok: false, error: "Variation count must be between 1 and 4" });
|
|
return;
|
|
}
|
|
const nextElevenlabs = {
|
|
apiKey: payload.elevenlabs?.apiKey === undefined ? current.apiKey : String(payload.elevenlabs.apiKey).trim(),
|
|
importPath,
|
|
variationCount,
|
|
loop: current.loop,
|
|
durationSeconds: payload.elevenlabs?.durationSeconds === null || payload.elevenlabs?.durationSeconds === ""
|
|
? null
|
|
: Number(payload.elevenlabs?.durationSeconds ?? current.durationSeconds),
|
|
promptInfluence: Number(payload.elevenlabs?.promptInfluence ?? current.promptInfluence),
|
|
modelId: String(payload.elevenlabs?.modelId || current.modelId),
|
|
};
|
|
if (nextElevenlabs.durationSeconds !== null && (!Number.isFinite(nextElevenlabs.durationSeconds) || nextElevenlabs.durationSeconds < 0.5 || nextElevenlabs.durationSeconds > 30)) {
|
|
json(res, 400, { ok: false, error: "Default ElevenLabs duration must be Auto or between 0.5 and 30 seconds." });
|
|
return;
|
|
}
|
|
if (!Number.isFinite(nextElevenlabs.promptInfluence) || nextElevenlabs.promptInfluence < 0 || nextElevenlabs.promptInfluence > 1) {
|
|
json(res, 400, { ok: false, error: "Default ElevenLabs prompt influence must be between 0 and 1." });
|
|
return;
|
|
}
|
|
if (nextElevenlabs.modelId !== "eleven_text_to_sound_v2") {
|
|
json(res, 400, { ok: false, error: "Unsupported ElevenLabs Sound Effects model." });
|
|
return;
|
|
}
|
|
INTEGRATIONS.elevenlabs = nextElevenlabs;
|
|
const improvePrompt = String(payload.textTools?.improvePrompt || currentTextTools.improvePrompt).trim();
|
|
const translateLanguages = Array.isArray(payload.textTools?.translateLanguages)
|
|
? payload.textTools.translateLanguages.map((x) => String(x).trim()).filter(Boolean).slice(0, 20)
|
|
: currentTextTools.translateLanguages;
|
|
if (!improvePrompt || improvePrompt.length > 2000) {
|
|
json(res, 400, { ok: false, error: "Text Fix prompt must contain 1 to 2000 characters." });
|
|
return;
|
|
}
|
|
if (!translateLanguages.length) {
|
|
json(res, 400, { ok: false, error: "Add at least one translation language." });
|
|
return;
|
|
}
|
|
INTEGRATIONS.textTools = { improvePrompt, translateLanguages };
|
|
await saveIntegrations();
|
|
json(res, 200, { ok: true, settings: publicSettings() });
|
|
return;
|
|
}
|
|
|
|
if (req.method === "POST" && req.url === "/api/text-tools/improve") {
|
|
const payload = await readBody(req);
|
|
const text = String(payload.text || "").trim();
|
|
if (!text || text.length > 12000) {
|
|
json(res, 400, { ok: false, error: "Text Fix expects 1 to 12000 characters." });
|
|
return;
|
|
}
|
|
try {
|
|
json(res, 200, { ok: true, text: await runTextTool(INTEGRATIONS.textTools.improvePrompt, text) });
|
|
} catch (e) {
|
|
json(res, 500, { ok: false, error: String(e?.message || e) });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (req.method === "POST" && req.url === "/api/text-tools/translate") {
|
|
const payload = await readBody(req);
|
|
const text = String(payload.text || "").trim();
|
|
const language = String(payload.language || "").trim();
|
|
if (!text || text.length > 12000) {
|
|
json(res, 400, { ok: false, error: "Translate expects 1 to 12000 characters." });
|
|
return;
|
|
}
|
|
if (!INTEGRATIONS.textTools.translateLanguages.includes(language)) {
|
|
json(res, 400, { ok: false, error: "Choose a configured translation language." });
|
|
return;
|
|
}
|
|
try {
|
|
const instruction = `Translate the text into ${language}. Preserve meaning, tone, paragraph structure, line breaks, asset paths, code, and Unreal identifiers.`;
|
|
json(res, 200, { ok: true, text: await runTextTool(instruction, text), language });
|
|
} catch (e) {
|
|
json(res, 500, { ok: false, error: String(e?.message || e) });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (req.method === "POST" && req.url === "/api/integrations/codex/login") {
|
|
try {
|
|
const child = spawn("cmd.exe", ["/c", "start", "", "codex", "login"], {
|
|
detached: true,
|
|
stdio: "ignore",
|
|
windowsHide: false,
|
|
});
|
|
child.unref();
|
|
json(res, 200, { ok: true, message: "Codex OAuth login opened in a separate terminal." });
|
|
} catch (e) {
|
|
json(res, 500, { ok: false, error: String(e?.message || e) });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (req.method === "POST" && req.url === "/api/elevenlabs/generate") {
|
|
const payload = await readBody(req);
|
|
const prompt = String(payload.prompt || "").trim();
|
|
if (!INTEGRATIONS.elevenlabs.apiKey) {
|
|
json(res, 400, { ok: false, error: "Add an ElevenLabs API key in Settings first." });
|
|
return;
|
|
}
|
|
if (!prompt || prompt.length > 450) {
|
|
json(res, 400, { ok: false, error: "Sound prompt must contain 1 to 450 characters." });
|
|
return;
|
|
}
|
|
try {
|
|
const defaults = INTEGRATIONS.elevenlabs;
|
|
const count = Number(payload.variationCount ?? defaults.variationCount);
|
|
const loop = payload.loop === undefined ? defaults.loop : !!payload.loop;
|
|
const durationSeconds = payload.durationSeconds === null || payload.durationSeconds === undefined || payload.durationSeconds === ""
|
|
? null
|
|
: Number(payload.durationSeconds);
|
|
const promptInfluence = Number(payload.promptInfluence ?? defaults.promptInfluence);
|
|
const modelId = String(payload.modelId || defaults.modelId);
|
|
if (!Number.isInteger(count) || count < 1 || count > 4) throw new Error("Variation count must be between 1 and 4.");
|
|
if (durationSeconds !== null && (!Number.isFinite(durationSeconds) || durationSeconds < 0.5 || durationSeconds > 30)) throw new Error("Duration must be Auto or between 0.5 and 30 seconds.");
|
|
if (!Number.isFinite(promptInfluence) || promptInfluence < 0 || promptInfluence > 1) throw new Error("Prompt influence must be between 0 and 1.");
|
|
if (modelId !== "eleven_text_to_sound_v2") throw new Error("Unsupported ElevenLabs Sound Effects model.");
|
|
const options = { loop, durationSeconds, promptInfluence, modelId };
|
|
const sounds = await Promise.all(Array.from({ length: count }, (_, index) => generateSoundEffect(prompt, index, options)));
|
|
json(res, 200, { ok: true, prompt, options, sounds });
|
|
} catch (e) {
|
|
json(res, 502, { ok: false, error: String(e?.message || e) });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (req.method === "GET" && req.url.startsWith("/api/elevenlabs/audio/")) {
|
|
const id = decodeURIComponent(req.url.split("/").pop().split("?")[0]);
|
|
const item = AUDIO_CACHE.get(id);
|
|
if (!item || !existsSync(item.filePath)) {
|
|
res.writeHead(404).end("audio not found");
|
|
return;
|
|
}
|
|
const bytes = await readFile(item.filePath);
|
|
res.writeHead(200, {
|
|
"Content-Type": "audio/wav",
|
|
"Content-Length": bytes.length,
|
|
"Cache-Control": "private, max-age=3600",
|
|
});
|
|
res.end(bytes);
|
|
return;
|
|
}
|
|
|
|
if (req.method === "POST" && req.url === "/api/elevenlabs/import") {
|
|
const payload = await readBody(req);
|
|
try {
|
|
const result = await importGeneratedSound(String(payload.id || ""));
|
|
json(res, 200, { ok: true, ...result });
|
|
} catch (e) {
|
|
json(res, 500, { ok: false, error: String(e?.message || e) });
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (req.method === "GET" && req.url === "/api/health") {
|
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ ok: true, model: MODEL, models: modelsForClient(), projectDir: PROJECT_DIR, mcp: MCP_SERVER_ENTRY, hasAuth: HAS_AUTH, tools: CACHED_TOOLS, ueToolCount: UE_TOOL_COUNT, integrations: publicSettings() }));
|
|
return;
|
|
}
|
|
|
|
if (req.method === "GET") {
|
|
await serveStatic(req, res);
|
|
return;
|
|
}
|
|
|
|
res.writeHead(405).end("method not allowed");
|
|
});
|
|
|
|
server.listen(PORT, HOST, () => {
|
|
console.log(`[mcp-agent-gateway] http://${HOST}:${PORT}`);
|
|
console.log(`[mcp-agent-gateway] model=${MODEL}`);
|
|
console.log(`[mcp-agent-gateway] project=${PROJECT_DIR}`);
|
|
console.log(`[mcp-agent-gateway] mcp=${MCP_SERVER_ENTRY}`);
|
|
if (!HAS_AUTH) {
|
|
console.warn("[mcp-agent-gateway] WARNING: no CLAUDE_CODE_OAUTH_TOKEN / ANTHROPIC_API_KEY found.");
|
|
console.warn("[mcp-agent-gateway] Run `claude setup-token` and put it in agent-gateway/auth.env — see README.md.");
|
|
}
|
|
primeTools(); // fire-and-forget: populate the total tool list
|
|
countMcpTools().then((n) => { UE_TOOL_COUNT = n; console.log(`[mcp tools] ue-blueprint exposes ${n} tools`); });
|
|
});
|