vibeeditor-render 0.3.3 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/ia.js +413 -0
- package/core/jobs.js +14 -4
- package/core/nucleo.js +4 -1
- package/core/servidor.js +35 -1
- package/host-dist/129.bundle.js +946 -0
- package/host-dist/129.bundle.js.map +1 -0
- package/host-dist/702.bundle.js +145 -0
- package/host-dist/702.bundle.js.map +1 -0
- package/host-dist/805.bundle.js +8306 -0
- package/host-dist/805.bundle.js.map +1 -0
- package/host-dist/bundle.js +92740 -758
- package/host-dist/bundle.js.map +1 -1
- package/package.json +5 -2
package/core/ia.js
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
// Motor de IA "Claude Code" do Editor IA: em vez de o site pagar a API por token, o Vibe Render roda o Claude Code
|
|
2
|
+
// instalado neste computador (`claude -p`, binário original da Anthropic), logado na assinatura da própria pessoa
|
|
3
|
+
// (Pro/Max): o gasto sai do limite do plano dela. Nada de login passa por aqui: o Claude Code usa o login que a
|
|
4
|
+
// pessoa fez nele, e o Vibe Render tira ANTHROPIC_API_KEY do ambiente para não cair na cobrança da API.
|
|
5
|
+
//
|
|
6
|
+
// As ferramentas continuam no site (os projetos moram no navegador, no OPFS): o Vibe Render expõe um servidor MCP
|
|
7
|
+
// local por conversa com as definições que o site mandou; cada chamada vira um evento "chamada" para o site, que
|
|
8
|
+
// roda a ferramenta e devolve o resultado (POST .../resultado). A pesquisa na internet usa o WebSearch/WebFetch
|
|
9
|
+
// do próprio Claude Code. Rotas (sessão do site conferida pelo servidor):
|
|
10
|
+
// GET /v1/ia/status {instalado, versao, logado, plano, email, erro}
|
|
11
|
+
// POST /v1/ia/conversas {sistema, mensagem, ferramentas, modelo, esforco, web, sessao?} → {id}
|
|
12
|
+
// GET /v1/ia/conversas/<id>/eventos?desde=N eventos a partir do N (espera até ~25 s por novidade)
|
|
13
|
+
// POST /v1/ia/conversas/<id>/resultado {chamada, conteudo, erro}
|
|
14
|
+
// POST /v1/ia/conversas/<id>/cancelar
|
|
15
|
+
// POST /v1/ia/mcp/<id> o servidor MCP (só o processo do claude, com o token da conversa)
|
|
16
|
+
const fs = require("node:fs");
|
|
17
|
+
const os = require("node:os");
|
|
18
|
+
const path = require("node:path");
|
|
19
|
+
const crypto = require("node:crypto");
|
|
20
|
+
const {spawn, execFile} = require("node:child_process");
|
|
21
|
+
|
|
22
|
+
// execuções ao mesmo tempo neste computador (cada uma é um `claude -p`); VIBE_IA_SIMULTANEAS muda (1 a 12)
|
|
23
|
+
const MAX_SIMULTANEAS = 6;
|
|
24
|
+
// sem notícia do site por este tempo (aba fechada ou recarregada no meio), a conversa é encerrada e libera a vaga.
|
|
25
|
+
// O site pergunta pelos eventos a cada ≤ 25 s e, enquanto roda uma ferramenta longa, avisa que está vivo (POST …/vivo).
|
|
26
|
+
const ABANDONO = 5 * 60000;
|
|
27
|
+
const ESPERA_EVENTOS = 25000;
|
|
28
|
+
const GUARDA_DEPOIS_DO_FIM = 10 * 60000; // o site pode reconectar e ler o fim depois
|
|
29
|
+
const PREFIXO_MCP = "mcp__vibe__";
|
|
30
|
+
const MODELOS = new Set(["claude-opus-5-5", "claude-sonnet-5-5", "claude-haiku-5-5", "claude-fable-5-1"]);
|
|
31
|
+
const ESFORCOS = new Set(["low", "medium", "high", "xhigh", "max"]);
|
|
32
|
+
const WEB = ["WebSearch", "WebFetch"];
|
|
33
|
+
|
|
34
|
+
const erro = (status, mensagem) => Object.assign(new Error(mensagem), {status});
|
|
35
|
+
|
|
36
|
+
/** onde está o executável do Claude Code (o .cmd do npm só chama o .exe: chamamos direto, sem shell) */
|
|
37
|
+
function acharClaude(env = process.env, plataforma = process.platform, casa = os.homedir()) {
|
|
38
|
+
const existe = (p) => {
|
|
39
|
+
try {
|
|
40
|
+
return fs.statSync(p).isFile();
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
if (env.VIBE_RENDER_CLAUDE && existe(env.VIBE_RENDER_CLAUDE)) return env.VIBE_RENDER_CLAUDE;
|
|
46
|
+
const pastas = String(env.PATH || env.Path || "").split(path.delimiter).filter(Boolean);
|
|
47
|
+
if (plataforma === "win32") {
|
|
48
|
+
pastas.push(path.join(casa, ".local", "bin"));
|
|
49
|
+
for (const d of pastas) {
|
|
50
|
+
const exe = path.join(d, "claude.exe");
|
|
51
|
+
if (existe(exe)) return exe;
|
|
52
|
+
const cmd = path.join(d, "claude.cmd");
|
|
53
|
+
if (existe(cmd)) {
|
|
54
|
+
const m = /"%dp0%\\([^"]+\.exe)"/i.exec(fs.readFileSync(cmd, "utf8"));
|
|
55
|
+
if (m && existe(path.join(d, m[1]))) return path.join(d, m[1]);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
pastas.push(path.join(casa, ".local", "bin"), path.join(casa, ".claude", "local"));
|
|
61
|
+
for (const d of pastas) if (existe(path.join(d, "claude"))) return path.join(d, "claude");
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** ambiente do claude: sem chave de API (senão cobra a API em vez da assinatura) e sem o contexto do computador */
|
|
66
|
+
function ambiente(env = process.env) {
|
|
67
|
+
const e = {...env};
|
|
68
|
+
delete e.ANTHROPIC_API_KEY;
|
|
69
|
+
delete e.ANTHROPIC_AUTH_TOKEN;
|
|
70
|
+
delete e.CLAUDECODE; // se o Vibe Render subiu de dentro de um Claude Code, o filho não pode achar que é aninhado
|
|
71
|
+
e.CLAUDE_CODE_DISABLE_CLAUDE_MDS = "1";
|
|
72
|
+
e.CLAUDE_CODE_DISABLE_AUTO_MEMORY = "1";
|
|
73
|
+
e.MCP_TOOL_TIMEOUT = String(2 * 60 * 60000); // preparar/render de vídeo longo roda no site e pode demorar
|
|
74
|
+
e.MCP_TIMEOUT = "30000";
|
|
75
|
+
return e;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** argumentos do claude -p para uma volta da conversa */
|
|
79
|
+
function argumentos({modelo, esforco, web, sessao, arqSistema, arqMcp}) {
|
|
80
|
+
const a = ["-p", "--output-format", "stream-json", "--verbose", "--include-partial-messages", "--input-format", "stream-json",
|
|
81
|
+
"--model", modelo, "--effort", esforco, "--system-prompt-file", arqSistema,
|
|
82
|
+
"--mcp-config", arqMcp, "--strict-mcp-config", "--setting-sources", "", "--disable-slash-commands",
|
|
83
|
+
"--permission-mode", "dontAsk", "--allowedTools", "mcp__vibe", ...(web ? WEB : []),
|
|
84
|
+
"--tools", ...(web ? WEB : [""])];
|
|
85
|
+
if (sessao) a.push("--resume", sessao);
|
|
86
|
+
return a;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** blocos da Messages API (o que as ferramentas do site devolvem) → conteúdo do MCP */
|
|
90
|
+
function paraMcp(conteudo) {
|
|
91
|
+
if (conteudo == null || conteudo === "") return [{type: "text", text: "ok"}];
|
|
92
|
+
if (typeof conteudo === "string") return [{type: "text", text: conteudo}];
|
|
93
|
+
const blocos = Array.isArray(conteudo) ? conteudo : [conteudo];
|
|
94
|
+
return blocos.map((b) => {
|
|
95
|
+
if (b && b.type === "image" && b.source && b.source.type === "base64") return {type: "image", data: b.source.data, mimeType: b.source.media_type};
|
|
96
|
+
if (b && b.type === "text") return {type: "text", text: String(b.text || "")};
|
|
97
|
+
return {type: "text", text: typeof b === "string" ? b : JSON.stringify(b)};
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** valida o pedido do site; o system prompt e as ferramentas vêm do site, mas os limites são daqui */
|
|
102
|
+
function validarConversa(d) {
|
|
103
|
+
if (!d || typeof d !== "object") throw erro(400, "corpo inválido");
|
|
104
|
+
if (typeof d.sistema !== "string" || !d.sistema.trim()) throw erro(400, "sistema: texto obrigatório");
|
|
105
|
+
if (!d.mensagem || typeof d.mensagem !== "object" || !Array.isArray(d.mensagem.content)) throw erro(400, "mensagem: {content: [...]}");
|
|
106
|
+
const ferramentas = Array.isArray(d.ferramentas) ? d.ferramentas : [];
|
|
107
|
+
if (ferramentas.length > 80) throw erro(400, "ferramentas demais");
|
|
108
|
+
for (const f of ferramentas) {
|
|
109
|
+
if (!f || !/^[a-z0-9_]{1,60}$/.test(String(f.name || ""))) throw erro(400, `nome de ferramenta inválido: ${f && f.name}`);
|
|
110
|
+
}
|
|
111
|
+
const modelo = MODELOS.has(d.modelo) ? d.modelo : "claude-opus-5-5";
|
|
112
|
+
const esforco = ESFORCOS.has(d.esforco) ? d.esforco : "high";
|
|
113
|
+
const sessao = d.sessao == null ? null : String(d.sessao);
|
|
114
|
+
if (sessao && !/^[0-9a-f-]{36}$/i.test(sessao)) throw erro(400, "sessao inválida");
|
|
115
|
+
return {sistema: d.sistema, mensagem: {role: "user", content: d.mensagem.content}, ferramentas, modelo, esforco, web: d.web === true, sessao};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** texto do erro que o claude devolveu, em português quando é o limite da assinatura */
|
|
119
|
+
function erroDoClaude(texto) {
|
|
120
|
+
const t = String(texto || "").trim();
|
|
121
|
+
if (/usage limit|limit reached|rate.?limit|5-hour|weekly limit|out of extra usage/i.test(t)) {
|
|
122
|
+
return `O limite da sua assinatura do Claude acabou por agora (${t.slice(0, 200)}). Espere renovar ou troque o motor para API em Configurações → Claude.`;
|
|
123
|
+
}
|
|
124
|
+
if (/not logged in|please run \/login|invalid api key|authentication/i.test(t)) return "O Claude Code deste computador não está logado. Abra um terminal, rode `claude` e faça o login com a sua conta.";
|
|
125
|
+
return t.slice(0, 1500) || "O Claude Code parou sem dizer o motivo.";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** encerra o processo e os filhos (no Windows o kill não leva a árvore junto) */
|
|
129
|
+
function encerrar(p) {
|
|
130
|
+
if (!p || p.exitCode !== null) return;
|
|
131
|
+
if (process.platform === "win32") execFile("taskkill", ["/pid", String(p.pid), "/T", "/F"], {windowsHide: true}, () => {});
|
|
132
|
+
else p.kill("SIGTERM");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* opc: {dirDados, porta() (do servidor, para o MCP), env, log, spawn (injetável), acharClaude (injetável), versao,
|
|
137
|
+
* abandono (ms, injetável nos testes)}
|
|
138
|
+
*/
|
|
139
|
+
function criarIa(opc) {
|
|
140
|
+
const env = opc.env || process.env;
|
|
141
|
+
const log = opc.log || (() => {});
|
|
142
|
+
const maxSimultaneas = Math.min(12, Math.max(1, Math.floor(Number(env.VIBE_IA_SIMULTANEAS)) || MAX_SIMULTANEAS));
|
|
143
|
+
const abandono = opc.abandono || ABANDONO;
|
|
144
|
+
const rodar = opc.spawn || spawn;
|
|
145
|
+
const dir = path.join(opc.dirDados, "ia");
|
|
146
|
+
const trabalho = path.join(dir, "trabalho"); // cwd fixo: o --resume procura a sessão pela pasta
|
|
147
|
+
const conversas = new Map();
|
|
148
|
+
let statusCache = null;
|
|
149
|
+
|
|
150
|
+
const bin = () => (opc.acharClaude || acharClaude)(env);
|
|
151
|
+
|
|
152
|
+
function executar(binario, args, ms = 20000) {
|
|
153
|
+
return new Promise((ok) => {
|
|
154
|
+
execFile(binario, args, {env: ambiente(env), timeout: ms, windowsHide: true}, (e, out, err) => ok({e, out: String(out || ""), err: String(err || "")}));
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** o Claude Code está instalado e logado? (cache de 30 s: a tela pergunta sempre que abre) */
|
|
159
|
+
async function status(forcar = false) {
|
|
160
|
+
if (!forcar && statusCache && Date.now() - statusCache.em < 30000) return statusCache.v;
|
|
161
|
+
const b = bin();
|
|
162
|
+
let v;
|
|
163
|
+
if (!b) v = {instalado: false, erro: "Não encontrei o Claude Code neste computador."};
|
|
164
|
+
else {
|
|
165
|
+
const [ver, auth] = await Promise.all([executar(b, ["--version"]), executar(b, ["auth", "status"])]);
|
|
166
|
+
let a = {};
|
|
167
|
+
try {
|
|
168
|
+
a = JSON.parse(auth.out);
|
|
169
|
+
} catch {
|
|
170
|
+
/* versão antiga sem JSON: fica sem os detalhes */
|
|
171
|
+
}
|
|
172
|
+
v = {instalado: true, versao: ver.out.trim().split(/\s/)[0] || null, logado: a.loggedIn === true,
|
|
173
|
+
metodo: a.authMethod || null, plano: a.subscriptionType || null, email: a.email || null};
|
|
174
|
+
if (v.logado && v.metodo !== "claude.ai") v.aviso = "O Claude Code deste computador está logado com chave de API: o gasto vai para a API, não para a assinatura.";
|
|
175
|
+
}
|
|
176
|
+
statusCache = {em: Date.now(), v};
|
|
177
|
+
return v;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function emitir(c, ev) {
|
|
181
|
+
c.eventos.push({...ev, n: c.eventos.length});
|
|
182
|
+
for (const f of c.ouvintes) f();
|
|
183
|
+
c.ouvintes.clear();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function terminar(c, fim) {
|
|
187
|
+
if (c.fim) return;
|
|
188
|
+
c.fim = fim;
|
|
189
|
+
for (const [, p] of c.pendentes) p.ok({erro: true, conteudo: "a conversa terminou"});
|
|
190
|
+
c.pendentes.clear();
|
|
191
|
+
emitir(c, {tipo: "fim", ...fim});
|
|
192
|
+
setTimeout(() => {
|
|
193
|
+
conversas.delete(c.id);
|
|
194
|
+
fs.rm(c.pasta, {recursive: true, force: true}, () => {});
|
|
195
|
+
}, GUARDA_DEPOIS_DO_FIM).unref();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** uma linha do stream-json do claude → eventos para o site */
|
|
199
|
+
function lerLinha(c, linha) {
|
|
200
|
+
let j;
|
|
201
|
+
try {
|
|
202
|
+
j = JSON.parse(linha);
|
|
203
|
+
} catch {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (j.type === "system" && j.subtype === "init") {
|
|
207
|
+
c.sessao = j.session_id || c.sessao;
|
|
208
|
+
emitir(c, {tipo: "inicio", sessao: c.sessao, modelo: j.model});
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (j.type === "stream_event" && j.event) {
|
|
212
|
+
const ev = j.event;
|
|
213
|
+
if (ev.type === "content_block_delta" && ev.delta && ev.delta.type === "text_delta") emitir(c, {tipo: "texto", delta: ev.delta.text});
|
|
214
|
+
if (ev.type === "content_block_start" && ev.content_block && ev.content_block.type === "tool_use") {
|
|
215
|
+
const nome = String(ev.content_block.name || "");
|
|
216
|
+
const nosso = nome.startsWith(PREFIXO_MCP);
|
|
217
|
+
emitir(c, {tipo: "ferramenta", nome: nosso ? nome.slice(PREFIXO_MCP.length) : nome, id: ev.content_block.id, servidor: !nosso});
|
|
218
|
+
}
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (j.type === "assistant" && j.message) {
|
|
222
|
+
for (const b of j.message.content || []) {
|
|
223
|
+
if (b.type === "tool_use" && WEB.includes(b.name)) c.web.set(b.id, b);
|
|
224
|
+
if (b.type === "text" && b.text) c.ultimoTexto = b.text;
|
|
225
|
+
}
|
|
226
|
+
if (j.message.usage) emitir(c, {tipo: "uso", uso: j.message.usage, modelo: j.message.model});
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (j.type === "user" && j.message && Array.isArray(j.message.content)) {
|
|
230
|
+
for (const b of j.message.content) {
|
|
231
|
+
const pedido = b.type === "tool_result" && c.web.get(b.tool_use_id);
|
|
232
|
+
if (!pedido) continue;
|
|
233
|
+
const alvo = String((pedido.input && (pedido.input.query || pedido.input.url)) || "").slice(0, 160);
|
|
234
|
+
const nome = pedido.name === "WebSearch" ? "web_search" : "web_fetch";
|
|
235
|
+
emitir(c, {tipo: "ferramenta_fim", nome, id: pedido.id, entrada: pedido.input, alvo, servidor: true, erro: !!b.is_error, resumo: b.is_error ? "erro" : (nome === "web_search" ? "resultados" : "página lida")});
|
|
236
|
+
}
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (j.type === "result") {
|
|
240
|
+
c.resultado = j;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function criar(corpo) {
|
|
245
|
+
const d = validarConversa(corpo);
|
|
246
|
+
varrer();
|
|
247
|
+
const ativas = [...conversas.values()].filter((c) => !c.fim).length;
|
|
248
|
+
if (ativas >= maxSimultaneas) throw erro(429, `Já há ${ativas} execuções da IA neste computador (o limite é ${maxSimultaneas}). Espere uma terminar ou pare uma delas.`);
|
|
249
|
+
const b = bin();
|
|
250
|
+
if (!b) throw erro(409, "Não encontrei o Claude Code neste computador. Instale (code.claude.com) e faça o login com a sua conta.");
|
|
251
|
+
|
|
252
|
+
const id = crypto.randomUUID();
|
|
253
|
+
const token = crypto.randomBytes(24).toString("hex");
|
|
254
|
+
const pasta = path.join(dir, "conversas", id);
|
|
255
|
+
fs.mkdirSync(pasta, {recursive: true});
|
|
256
|
+
fs.mkdirSync(trabalho, {recursive: true});
|
|
257
|
+
const arqSistema = path.join(pasta, "sistema.md");
|
|
258
|
+
const arqMcp = path.join(pasta, "mcp.json");
|
|
259
|
+
fs.writeFileSync(arqSistema, d.sistema);
|
|
260
|
+
fs.writeFileSync(arqMcp, JSON.stringify({mcpServers: {vibe: {type: "http", url: `http://127.0.0.1:${opc.porta()}/v1/ia/mcp/${id}`, headers: {Authorization: `Bearer ${token}`}}}}));
|
|
261
|
+
|
|
262
|
+
const c = {id, token, pasta, ferramentas: d.ferramentas, eventos: [], ouvintes: new Set(), pendentes: new Map(), web: new Map(),
|
|
263
|
+
sessao: d.sessao, ultimoTexto: "", resultado: null, fim: null, proc: null, saidaErro: "", vistoEm: Date.now()};
|
|
264
|
+
conversas.set(id, c);
|
|
265
|
+
|
|
266
|
+
const p = rodar(b, argumentos({...d, arqSistema, arqMcp}), {cwd: trabalho, env: ambiente(env), windowsHide: true, stdio: ["pipe", "pipe", "pipe"]});
|
|
267
|
+
c.proc = p;
|
|
268
|
+
let resto = "";
|
|
269
|
+
p.stdout.on("data", (buf) => {
|
|
270
|
+
resto += buf.toString("utf8");
|
|
271
|
+
const linhas = resto.split("\n");
|
|
272
|
+
resto = linhas.pop();
|
|
273
|
+
for (const l of linhas) if (l.trim()) lerLinha(c, l);
|
|
274
|
+
});
|
|
275
|
+
p.stderr.on("data", (buf) => {
|
|
276
|
+
c.saidaErro = (c.saidaErro + buf.toString("utf8")).slice(-4000);
|
|
277
|
+
});
|
|
278
|
+
p.on("error", (e) => terminar(c, {motivo: "erro", erro: `Não consegui abrir o Claude Code: ${e.message}`}));
|
|
279
|
+
p.on("close", (code) => {
|
|
280
|
+
if (resto.trim()) lerLinha(c, resto);
|
|
281
|
+
const r = c.resultado;
|
|
282
|
+
if (c.cancelado) return terminar(c, {motivo: "parado", sessao: c.sessao});
|
|
283
|
+
if (r && !r.is_error && r.subtype === "success") {
|
|
284
|
+
return terminar(c, {motivo: "fim", sessao: r.session_id || c.sessao, texto: String(r.result || c.ultimoTexto || ""), custo: r.total_cost_usd, uso: r.usage, voltas: r.num_turns});
|
|
285
|
+
}
|
|
286
|
+
if (r && r.subtype === "error_max_turns") return terminar(c, {motivo: "limite", sessao: c.sessao});
|
|
287
|
+
const texto = (r && (r.result || (r.errors || []).join(" "))) || c.saidaErro || `saiu com código ${code}`;
|
|
288
|
+
log("ia", `claude terminou com erro: ${String(texto).slice(0, 300)}`);
|
|
289
|
+
terminar(c, {motivo: "erro", sessao: c.sessao, erro: erroDoClaude(texto)});
|
|
290
|
+
});
|
|
291
|
+
// a mensagem da pessoa vai pelo stdin (stream-json aceita imagens e texto longo sem passar pela linha de comando)
|
|
292
|
+
p.stdin.on("error", () => {});
|
|
293
|
+
p.stdin.end(`${JSON.stringify({type: "user", message: d.mensagem})}\n`);
|
|
294
|
+
log("ia", `conversa ${id.slice(0, 8)} · ${d.modelo} · ${d.ferramentas.length} ferramentas${d.sessao ? " · continuando" : ""}`);
|
|
295
|
+
return {id};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** eventos a partir de `desde`; se ainda não há nada novo, espera até ESPERA_EVENTOS */
|
|
299
|
+
function eventos(id, desde, sinal) {
|
|
300
|
+
const c = conversas.get(id);
|
|
301
|
+
if (!c) return Promise.resolve(null);
|
|
302
|
+
c.vistoEm = Date.now();
|
|
303
|
+
const pegar = () => ({eventos: c.eventos.slice(desde), fim: !!c.fim});
|
|
304
|
+
if (c.eventos.length > desde || c.fim) return Promise.resolve(pegar());
|
|
305
|
+
return new Promise((ok) => {
|
|
306
|
+
const pronto = () => {
|
|
307
|
+
clearTimeout(t);
|
|
308
|
+
ok(pegar());
|
|
309
|
+
};
|
|
310
|
+
const t = setTimeout(() => {
|
|
311
|
+
c.ouvintes.delete(pronto);
|
|
312
|
+
ok(pegar());
|
|
313
|
+
}, ESPERA_EVENTOS);
|
|
314
|
+
c.ouvintes.add(pronto);
|
|
315
|
+
if (sinal) sinal.once("close", () => {
|
|
316
|
+
clearTimeout(t); c.ouvintes.delete(pronto);
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function resultado(id, corpo) {
|
|
322
|
+
const c = conversas.get(id);
|
|
323
|
+
if (!c) throw erro(404, "conversa não encontrada");
|
|
324
|
+
c.vistoEm = Date.now();
|
|
325
|
+
const p = c.pendentes.get(String(corpo && corpo.chamada));
|
|
326
|
+
if (!p) throw erro(404, "chamada não encontrada (já respondida ou a conversa terminou)");
|
|
327
|
+
c.pendentes.delete(String(corpo.chamada));
|
|
328
|
+
p.ok({erro: corpo.erro === true, conteudo: corpo.conteudo});
|
|
329
|
+
return {ok: true};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** o site ainda está com a conversa aberta (ele chama enquanto roda uma ferramenta demorada) */
|
|
333
|
+
function vivo(id) {
|
|
334
|
+
const c = conversas.get(id);
|
|
335
|
+
if (!c) throw erro(404, "conversa não encontrada");
|
|
336
|
+
c.vistoEm = Date.now();
|
|
337
|
+
return {ok: true, fim: !!c.fim};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Conversa sem notícia do site há mais de `abandono` (a aba fechou ou recarregou no meio): o claude estaria esperando
|
|
342
|
+
* para sempre a resposta de uma ferramenta que ninguém vai rodar. Encerra e libera a vaga.
|
|
343
|
+
*/
|
|
344
|
+
function varrer(agora = Date.now()) {
|
|
345
|
+
for (const c of conversas.values()) {
|
|
346
|
+
if (c.fim || agora - c.vistoEm <= abandono) continue;
|
|
347
|
+
log("ia", `conversa ${c.id.slice(0, 8)} sem o site há ${Math.round((agora - c.vistoEm) / 60000)} min: encerrada (aba fechada no meio?)`);
|
|
348
|
+
c.cancelado = true;
|
|
349
|
+
c.abandonada = true;
|
|
350
|
+
for (const [, p] of c.pendentes) p.ok({erro: true, conteudo: "o site fechou a conversa"});
|
|
351
|
+
c.pendentes.clear();
|
|
352
|
+
encerrar(c.proc);
|
|
353
|
+
// processo que não fecha (ou já tinha fechado sem avisar): a vaga não pode ficar presa
|
|
354
|
+
setTimeout(() => terminar(c, {motivo: "parado", sessao: c.sessao}), 15000).unref();
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
const relogio = setInterval(() => varrer(), 30000);
|
|
358
|
+
if (relogio.unref) relogio.unref();
|
|
359
|
+
|
|
360
|
+
function cancelar(id) {
|
|
361
|
+
const c = conversas.get(id);
|
|
362
|
+
if (!c) throw erro(404, "conversa não encontrada");
|
|
363
|
+
if (!c.fim) {
|
|
364
|
+
c.cancelado = true;
|
|
365
|
+
encerrar(c.proc);
|
|
366
|
+
}
|
|
367
|
+
return {ok: true};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** o servidor MCP da conversa (JSON-RPC por HTTP, respostas em JSON) */
|
|
371
|
+
async function mcp(id, autorizacao, mensagem) {
|
|
372
|
+
const c = conversas.get(id);
|
|
373
|
+
const tk = String(autorizacao || "").replace(/^Bearer\s+/i, "");
|
|
374
|
+
if (!c || !tk || tk.length !== c.token.length || !crypto.timingSafeEqual(Buffer.from(tk), Buffer.from(c.token))) throw erro(401, "token");
|
|
375
|
+
if (Array.isArray(mensagem)) return (await Promise.all(mensagem.map((m) => responder(c, m)))).filter(Boolean);
|
|
376
|
+
return responder(c, mensagem);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function responder(c, m) {
|
|
380
|
+
if (!m || typeof m !== "object") return {jsonrpc: "2.0", id: null, error: {code: -32600, message: "pedido inválido"}};
|
|
381
|
+
if (m.id === undefined || m.id === null) return null; // notificação: nada a responder
|
|
382
|
+
const ok = (result) => ({jsonrpc: "2.0", id: m.id, result});
|
|
383
|
+
const p = m.params || {};
|
|
384
|
+
if (m.method === "initialize") {
|
|
385
|
+
return ok({protocolVersion: p.protocolVersion || "2025-06-18", capabilities: {tools: {}}, serverInfo: {name: "vibe", version: opc.versao || "0"}});
|
|
386
|
+
}
|
|
387
|
+
if (m.method === "ping") return ok({});
|
|
388
|
+
if (m.method === "tools/list") {
|
|
389
|
+
return ok({tools: c.ferramentas.map((f) => ({name: f.name, description: f.description || "", inputSchema: f.input_schema || {type: "object"}}))});
|
|
390
|
+
}
|
|
391
|
+
if (m.method === "tools/call") {
|
|
392
|
+
if (c.fim) return ok({content: [{type: "text", text: "a conversa terminou"}], isError: true});
|
|
393
|
+
const chamada = crypto.randomUUID();
|
|
394
|
+
const r = await new Promise((resolver) => {
|
|
395
|
+
c.pendentes.set(chamada, {ok: resolver});
|
|
396
|
+
emitir(c, {tipo: "chamada", chamada, nome: String(p.name || ""), entrada: p.arguments || {}});
|
|
397
|
+
});
|
|
398
|
+
return ok({content: paraMcp(r.conteudo), isError: !!r.erro});
|
|
399
|
+
}
|
|
400
|
+
return {jsonrpc: "2.0", id: m.id, error: {code: -32601, message: `método desconhecido: ${m.method}`}};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function cancelarTodas() {
|
|
404
|
+
for (const c of conversas.values()) if (!c.fim) {
|
|
405
|
+
c.cancelado = true;
|
|
406
|
+
encerrar(c.proc);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return {status, criar, eventos, resultado, vivo, cancelar, mcp, cancelarTodas, _conversas: conversas, _varrer: varrer, maxSimultaneas};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
module.exports = {criarIa, acharClaude, argumentos, paraMcp, validarConversa, erroDoClaude, ambiente};
|
package/core/jobs.js
CHANGED
|
@@ -45,7 +45,9 @@ const NOMES_NORMALIZAVEIS = ["original"];
|
|
|
45
45
|
/** quantas vezes um job tenta renderizar quando a falha é passageira (do compositor, não do código do vídeo) */
|
|
46
46
|
const TENTATIVAS = 2;
|
|
47
47
|
/** erros do compositor do Remotion que não dependem do código nem dos arquivos: vale tentar de novo */
|
|
48
|
-
|
|
48
|
+
// delayRender/timeout: quadro que demorou a carregar (vídeo-base pesado, máquina ocupada) — repete com 1 aba, como o
|
|
49
|
+
// hv render do editor local (ee515e1)
|
|
50
|
+
const falhaPassageira = (e) => /No frame found at position|Compositor exited|Compositor quit|Request closed|delayRender|timed out|timeout/i.test(String((e && e.message) || e));
|
|
49
51
|
|
|
50
52
|
/** valida o corpo do POST /v1/jobs; devolve a especificação limpa ou lança {status, message} */
|
|
51
53
|
function validarJob(d, cache) {
|
|
@@ -223,12 +225,14 @@ function criarJobs(opc) {
|
|
|
223
225
|
ffmpeg = await normalizarOriginais(j, ffmpeg, cancel);
|
|
224
226
|
const ativos = Object.fromEntries(Object.entries(j.arquivos).map(([nome, sha]) => [nome, opc.urlAtivo(j.id, sha, j.token)]));
|
|
225
227
|
const inputProps = {codigo: j.codigo, props: j.props, ativos, composicao: j.composicao};
|
|
226
|
-
|
|
228
|
+
// 3D (three.js / kit Motion explicativo) precisa de WebGL de verdade: "angle" usa a placa de vídeo pelo ANGLE
|
|
229
|
+
const gl = usaWebGL(j.codigo) ? "angle" : null;
|
|
230
|
+
const comum = {browserExecutable: opc.navegador || null, binariesDirectory: opc.binarios || null, chromeMode: "headless-shell", logLevel: "warn", ...(gl ? {chromiumOptions: {gl}} : {})};
|
|
227
231
|
const p = PRESETS[j.preset];
|
|
228
232
|
fs.mkdirSync(opc.dirTmp, {recursive: true});
|
|
229
233
|
for (let tentativa = 1; ; tentativa++) {
|
|
230
234
|
try {
|
|
231
|
-
browser = await R.openBrowser("chrome", {browserExecutable: comum.browserExecutable, chromeMode: "headless-shell", logLevel: "warn"});
|
|
235
|
+
browser = await R.openBrowser("chrome", {browserExecutable: comum.browserExecutable, chromeMode: "headless-shell", logLevel: "warn", ...(gl ? {chromiumOptions: {gl}} : {})});
|
|
232
236
|
const composition = await R.selectComposition({...comum, serveUrl: opc.hostDir, id: "VibeHost", inputProps, puppeteerInstance: browser});
|
|
233
237
|
mudar(j, {status: "renderizando", etapa: "Renderizando quadros"});
|
|
234
238
|
// na repetição vai com 1 aba: vídeo com GOP longo / fps variável (ex.: WhatsApp, 1 quadro-chave a cada 10 s)
|
|
@@ -307,4 +311,10 @@ function criarJobs(opc) {
|
|
|
307
311
|
return {criar, obter, interno, cancelar, ouvir, ativos, emUso: () => new Set([...jobs.values()].filter((j) => j.status !== "pronto").flatMap((j) => [...Object.values(j.arquivos || {}), ...Object.values(j.originais || {})]))};
|
|
308
312
|
}
|
|
309
313
|
|
|
310
|
-
|
|
314
|
+
/** o código usa 3D (kit Motion explicativo, three.js)? Então o render precisa de WebGL de verdade (gl "angle") */
|
|
315
|
+
function usaWebGL(codigo) {
|
|
316
|
+
const c = String(codigo || "");
|
|
317
|
+
return ["@vibe/explicativo", "@remotion/three", "@react-three/fiber", 'require("three")', "require('three')"].some((m) => c.includes(m));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
module.exports = {criarJobs, validarJob, escalaDo, concorrencia, nomeSaida, falhaPassageira, usaWebGL, TENTATIVAS, NOMES_NORMALIZAVEIS};
|
package/core/nucleo.js
CHANGED
|
@@ -7,6 +7,7 @@ const {criarPareamento} = require("./pareamento");
|
|
|
7
7
|
const {criarServidor} = require("./servidor");
|
|
8
8
|
const {criarTranscricoes} = require("./transcricao");
|
|
9
9
|
const {criarVoz} = require("./voz");
|
|
10
|
+
const {criarIa} = require("./ia");
|
|
10
11
|
const {LIMITES, origensPermitidas, STATUS_INTERVALO} = require("./config");
|
|
11
12
|
|
|
12
13
|
/**
|
|
@@ -25,7 +26,8 @@ async function criarNucleo(opc) {
|
|
|
25
26
|
});
|
|
26
27
|
const transcricoes = criarTranscricoes({cache, dirDados: opc.dirDados, dirTmp: path.join(opc.dirDados, "tmp"), whisper: opc.whisper});
|
|
27
28
|
const voz = criarVoz({dirDados: opc.dirDados, env, urlInicial: opc.voiceStudio, log: opc.log});
|
|
28
|
-
|
|
29
|
+
const ia = criarIa({dirDados: opc.dirDados, env, versao: opc.versao, porta: () => servidor.porta(), log: opc.log});
|
|
30
|
+
servidor = criarServidor({versao: opc.versao, origens: origensPermitidas(env), cache, jobs, transcricoes, voz, ia, pareamento, mostrarArquivo: opc.mostrarArquivo, log: opc.log});
|
|
29
31
|
const porta = await servidor.iniciar(opc.portas);
|
|
30
32
|
pareamento.conferir();
|
|
31
33
|
voz.aoIniciar(); // abre o VoiceStudio junto, se a pessoa pediu (Configurações → VoiceStudio)
|
|
@@ -37,6 +39,7 @@ async function criarNucleo(opc) {
|
|
|
37
39
|
clearInterval(relogio);
|
|
38
40
|
for (const j of jobs.ativos()) jobs.cancelar(j.id);
|
|
39
41
|
transcricoes.cancelarTodas();
|
|
42
|
+
ia.cancelarTodas();
|
|
40
43
|
await servidor.parar();
|
|
41
44
|
},
|
|
42
45
|
};
|
package/core/servidor.js
CHANGED
|
@@ -99,6 +99,24 @@ function criarServidor(opc) {
|
|
|
99
99
|
return enviarArquivo(req, res, opc.cache.caminho(sha), {"Access-Control-Allow-Origin": "*", "Cache-Control": "no-store"});
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
// servidor MCP da conversa de IA: quem chama é o processo do claude deste computador (sem Origin), com o token
|
|
103
|
+
// aleatório da conversa; nenhum navegador tem esse token (core/ia.js)
|
|
104
|
+
if (partes[0] === "v1" && partes[1] === "ia" && partes[2] === "mcp" && partes.length === 4 && opc.ia) {
|
|
105
|
+
if (req.headers.origin) return json(403, {erro: "origem não permitida"});
|
|
106
|
+
if (req.method !== "POST") {
|
|
107
|
+
res.writeHead(405, {Allow: "POST"}); return res.end();
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
const r = await opc.ia.mcp(partes[3], req.headers.authorization, await lerJson(req, LIMITES.jsonBytes));
|
|
111
|
+
if (r === null || (Array.isArray(r) && !r.length)) {
|
|
112
|
+
res.writeHead(202); return res.end();
|
|
113
|
+
}
|
|
114
|
+
return json(200, r);
|
|
115
|
+
} catch (e) {
|
|
116
|
+
return json(e.status || 500, {erro: e.status ? e.message : "erro interno"});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
102
120
|
const origin = req.headers.origin;
|
|
103
121
|
if (!origemPermitida(origin, opc.origens)) return json(403, {erro: "origem não permitida"});
|
|
104
122
|
const cors = {
|
|
@@ -116,7 +134,7 @@ function criarServidor(opc) {
|
|
|
116
134
|
const p = opc.pareamento.visao();
|
|
117
135
|
return json(200, {app: "vibe-render", versao: opc.versao, pareado: p.pareado, dispositivoId: p.dispositivoId, email: p.email,
|
|
118
136
|
plataforma: process.platform, porta, acessoPlataforma: p.plataforma,
|
|
119
|
-
recursos: [...(opc.transcricoes ? ["transcricao"] : []), ...(opc.voz ? ["voz", "voz-abrir"] : [])]}, cors);
|
|
137
|
+
recursos: [...(opc.transcricoes ? ["transcricao"] : []), ...(opc.voz ? ["voz", "voz-abrir"] : []), ...(opc.ia ? ["ia"] : [])]}, cors);
|
|
120
138
|
}
|
|
121
139
|
|
|
122
140
|
// daqui para baixo: sessão válida deste aparelho
|
|
@@ -161,6 +179,22 @@ function criarServidor(opc) {
|
|
|
161
179
|
if (!opc.pareamento.visao().plataforma) return json(403, {erro: "Sua conta está sem a Plataforma."}, cors);
|
|
162
180
|
if (await opc.voz.rota(req, res, partes.slice(2), {json, cors})) return;
|
|
163
181
|
}
|
|
182
|
+
// motor de IA "Claude Code" (assinatura da pessoa): core/ia.js
|
|
183
|
+
if (partes[0] === "v1" && partes[1] === "ia" && opc.ia) {
|
|
184
|
+
if (!opc.pareamento.visao().plataforma) return json(403, {erro: "Sua conta está sem a Plataforma."}, cors);
|
|
185
|
+
if (partes[2] === "status" && partes.length === 3 && req.method === "GET") return json(200, await opc.ia.status(u.searchParams.get("forcar") === "1"), cors);
|
|
186
|
+
if (partes[2] === "conversas" && partes.length === 3 && req.method === "POST") return json(201, await opc.ia.criar(await lerJson(req, LIMITES.jsonBytes)), cors);
|
|
187
|
+
if (partes[2] === "conversas" && partes[3] && partes.length === 5) {
|
|
188
|
+
const id = partes[3];
|
|
189
|
+
if (partes[4] === "eventos" && req.method === "GET") {
|
|
190
|
+
const r = await opc.ia.eventos(id, Math.max(0, Number(u.searchParams.get("desde")) || 0), req);
|
|
191
|
+
return r ? json(200, r, cors) : json(404, {erro: "conversa não encontrada"}, cors);
|
|
192
|
+
}
|
|
193
|
+
if (partes[4] === "resultado" && req.method === "POST") return json(200, opc.ia.resultado(id, await lerJson(req, LIMITES.jsonBytes)), cors);
|
|
194
|
+
if (partes[4] === "cancelar" && req.method === "POST") return json(200, opc.ia.cancelar(id), cors);
|
|
195
|
+
if (partes[4] === "vivo" && req.method === "POST") return json(200, opc.ia.vivo(id), cors);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
164
198
|
if (partes[0] === "v1" && partes[1] === "jobs" && partes[2]) {
|
|
165
199
|
const id = partes[2];
|
|
166
200
|
const j = opc.jobs.obter(id);
|