onChange(resolve(s))} />;
}
const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
const idx = Math.max(0, opts.findIndex((o) => o.value === value));
const n = opts.length;
const segAt = (clientX) => {
const r = trackRef.current.getBoundingClientRect();
const inner = r.width - 4;
const i = Math.floor(((clientX - r.left - 2) / inner) * n);
return opts[Math.max(0, Math.min(n - 1, i))].value;
};
const onPointerDown = (e) => {
setDragging(true);
const v0 = segAt(e.clientX);
if (v0 !== valueRef.current) onChange(v0);
const move = (ev) => {
if (!trackRef.current) return;
const v = segAt(ev.clientX);
if (v !== valueRef.current) onChange(v);
};
const up = () => {
setDragging(false);
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
};
return (
{opts.map((o) => (
{o.label}
))}
);
}
function TweakSelect({ label, value, options, onChange }) {
return (
onChange(e.target.value)}>
{options.map((o) => {
const v = typeof o === 'object' ? o.value : o;
const l = typeof o === 'object' ? o.label : o;
return {l} ;
})}
);
}
function TweakText({ label, value, placeholder, onChange }) {
return (
onChange(e.target.value)} />
);
}
function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) {
const clamp = (n) => {
if (min != null && n < min) return min;
if (max != null && n > max) return max;
return n;
};
const startRef = React.useRef({ x: 0, val: 0 });
const onScrubStart = (e) => {
e.preventDefault();
startRef.current = { x: e.clientX, val: value };
const decimals = (String(step).split('.')[1] || '').length;
const move = (ev) => {
const dx = ev.clientX - startRef.current.x;
const raw = startRef.current.val + dx * step;
const snapped = Math.round(raw / step) * step;
onChange(clamp(Number(snapped.toFixed(decimals))));
};
const up = () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
};
return (
{label}
onChange(clamp(Number(e.target.value)))} />
{unit && {unit} }
);
}
// Relative-luminance contrast pick — checkmarks drawn over a swatch need to
// read on both #111 and #fafafa without per-option configuration. Hex input
// only (#rgb / #rrggbb); named or rgb()/hsl() colors fall through to "light".
function __twkIsLight(hex) {
const h = String(hex).replace('#', '');
const x = h.length === 3 ? h.replace(/./g, (c) => c + c) : h.padEnd(6, '0');
const n = parseInt(x.slice(0, 6), 16);
if (Number.isNaN(n)) return true;
const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
return r * 299 + g * 587 + b * 114 > 148000;
}
const __TwkCheck = ({ light }) => (
);
// TweakColor — curated color/palette picker. Each option is either a single
// hex string or an array of 1-5 hex strings; the card adapts — a lone color
// renders solid, a palette renders colors[0] as the hero (left ~2/3) with the
// rest stacked in a sharp column on the right. onChange emits the
// option in the shape it was passed (string stays string, array stays array).
// Without options it falls back to the native color input for back-compat.
function TweakColor({ label, value, options, onChange }) {
if (!options || !options.length) {
return (
);
}
// Native emits lowercase hex per the HTML spec, so
// compare case-insensitively. String() guards JSON.stringify(undefined),
// which returns the primitive undefined (no .toLowerCase).
const key = (o) => String(JSON.stringify(o)).toLowerCase();
const cur = key(value);
return (
{options.map((o, i) => {
const colors = Array.isArray(o) ? o : [o];
const [hero, ...rest] = colors;
const sup = rest.slice(0, 4);
const on = key(o) === cur;
return (
onChange(o)}>
{sup.length > 0 && (
{sup.map((c, j) => )}
)}
{on && <__TwkCheck light={__twkIsLight(hero)} />}
);
})}
);
}
function TweakButton({ label, onClick, secondary = false }) {
return (
{label}
);
}
Object.assign(window, {
useTweaks, TweaksPanel, TweakSection, TweakRow,
TweakSlider, TweakToggle, TweakRadio, TweakSelect,
TweakText, TweakNumber, TweakColor, TweakButton,
});
/* ===== brand.jsx ===== */
// brand.jsx — Kobar Labs brand marks recreated as crisp SVG
// Exports to window: KMonogram, KobarWordmark, OliveLoader, OliveMark,
// TatreezTexture, CircuitTexture, ConnectorGlyph
// ---- K monogram (charcoal K + copper square + copper "speed lines") ----
function KMonogram({ size = 34, mono = false, style = {} }) {
const ink = mono ? "currentColor" : "var(--charcoal)";
const cu = mono ? "currentColor" : "var(--copper)";
return (
{/* speed dashes */}
{/* copper chip square */}
{/* stem */}
{/* upper arm */}
{/* lower arm */}
);
}
function KobarWordmark({ size = 22, tagline = false, mono = false, style = {} }) {
const ink = mono ? "currentColor" : "var(--charcoal)";
return (
Kobar Labs
{tagline && (
AI · AUTOMATION · INTELLIGENCE
)}
);
}
// ---- Olive Grove mark / loader ----
// A pinwheel ring of olive leaves with copper berries, swirling.
const LEAF = "M0 0 C 6.4 -3.6, 6.4 -16.5, 0 -22 C -6.4 -16.5, -6.4 -3.6, 0 0 Z";
function OliveMark({ size = 88, spinning = false, speed = 1, style = {} }) {
const leaves = 11;
const olives = ["var(--olive)", "color-mix(in oklab, var(--olive), black 16%)",
"color-mix(in oklab, var(--olive), var(--warm-stone) 22%)"];
const dur = spinning ? (3.4 / speed) : 26;
return (
{Array.from({ length: leaves }).map((_, i) => {
const a = (i / leaves) * 360;
const r = 30;
// swirl: tangential offset + scale variance
const scale = 0.78 + (i % 3) * 0.12;
return (
);
})}
{/* copper berries */}
{[0, 3, 6, 8].map((i) => {
const a = (i / leaves) * 360;
return (
);
})}
{/* center copper dot */}
);
}
// Convenience loader (spinning, with optional pulse glow)
function OliveLoader({ size = 30, speed = 1, style = {} }) {
return ;
}
// ---- Texture: Tatreez (data pattern) — subtle pixel cross-stitch motif ----
function TatreezTexture({ id = "tatreez", color = "var(--olive)", accent = "var(--copper)", opacity = 0.5 }) {
const cells = [];
const motif = [
[2, 0], [1, 1], [2, 1], [3, 1], [0, 2], [2, 2], [4, 2],
[1, 3], [3, 3], [2, 4],
];
motif.forEach(([x, y], k) => {
cells.push(
);
});
return (
{cells}
);
}
// ---- Texture: Circuit pathways (line + node flow) for canvas / panels ----
function CircuitTexture({ id = "circuit", color = "var(--copper)", opacity = 0.18 }) {
return (
);
}
Object.assign(window, {
KMonogram, KobarWordmark, OliveLoader, OliveMark, TatreezTexture, CircuitTexture,
});
/* ===== icons.jsx ===== */
// icons.jsx — line icons (UI), connector glyphs, provider marks
// Exports: Icon, Connector, ProviderMark, CONNECTORS, PROVIDERS
const ICONS = {
plus: "M12 5v14M5 12h14",
search: "M11 19a8 8 0 100-16 8 8 0 000 16zM21 21l-4.3-4.3",
send: "M7 11l5-5 5 5M12 6v13",
chat: "M21 12a8 8 0 01-11.5 7.2L4 20l1.1-4.3A8 8 0 1121 12z",
flows: "M5 6h6M5 12h14M5 18h9",
connect: "M8 8L3 13l4 4 5-5M16 16l5-5-4-4-5 5M9 15l6-6",
runs: "M6 4l14 8-14 8V4z",
models: "M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3zM12 12l8-4.5M12 12v9M12 12L4 7.5",
chart: "M4 19V5M4 19h17M8 16v-5M12 16V8M16 16v-9M20 16v-3",
skills: "M12 3l2.4 5.9L21 9.3l-4.8 4.1L17.7 21 12 17.3 6.3 21l1.5-7.6L3 9.3l6.6-.4L12 3z",
agents: "M9 11a3 3 0 116 0M5 20a7 7 0 0114 0M12 2v3",
history: "M3 12a9 9 0 109-9 9 9 0 00-7 3.4M3 4v4h4M12 8v4l3 2",
settings: "M12 9a3 3 0 100 6 3 3 0 000-6zM19.4 13a7.8 7.8 0 000-2l2-1.5-2-3.4-2.4 1a7.6 7.6 0 00-1.7-1l-.4-2.6H10.7l-.4 2.6a7.6 7.6 0 00-1.7 1l-2.4-1-2 3.4L4.6 11a7.8 7.8 0 000 2l-2 1.5 2 3.4 2.4-1a7.6 7.6 0 001.7 1l.4 2.6h3.5l.4-2.6a7.6 7.6 0 001.7-1l2.4 1 2-3.4z",
chevDown: "M6 9l6 6 6-6",
chevRight: "M9 6l6 6-6 6",
chevLeft: "M15 6l-6 6 6 6",
x: "M6 6l12 12M18 6L6 18",
panel: "M4 5h16v14H4zM10 5v14",
edit: "M4 20h4L19 9l-4-4L4 16v4zM14 6l4 4",
trash: "M5 7h14M9 7V5h6v2M6 7l1 13h10l1-13",
copy: "M9 9h10v10H9zM5 15V5h10",
paperclip: "M20 11l-8 8a5 5 0 01-7-7l8-8a3.3 3.3 0 014.7 4.7l-8 8a1.6 1.6 0 01-2.3-2.3L15 8",
image: "M4 5h16v14H4zM4 16l4.5-4.5 4 4 3-3L20 16M9 10a1.2 1.2 0 100-2.4 1.2 1.2 0 000 2.4z",
fileDoc: "M7 3h7l5 5v13H7zM14 3v5h5M10 13h6M10 16h6M10 10h2",
audioFile: "M9 18V7l10-2v9M9 18a2.5 2.5 0 11-2-2.45M19 14a2.5 2.5 0 11-2-2.45",
check: "M5 12l5 5 9-11",
tag: "M3 12V4h8l9 9-8 8-9-9zM7.5 7.5h.01",
globe: "M12 3a9 9 0 100 18 9 9 0 000-18zM3 12h18M12 3c2.5 2.5 2.5 15 0 18M12 3c-2.5 2.5-2.5 15 0 18",
bolt: "M13 3L5 13h6l-1 8 8-10h-6l1-8z",
brackets: "M8 4C5 4 5 8 5 10s-2 2-2 2 2 0 2 2 0 6 3 6M16 4c3 0 3 4 3 6s2 2 2 2-2 0-2 2 0 6-3 6",
brain: "M9 4a3 3 0 00-3 3v1a3 3 0 000 6v1a3 3 0 003 3M15 4a3 3 0 013 3v1a3 3 0 010 6v1a3 3 0 01-3 3M9 4c1.5 0 3 1.2 3 3v11M15 4c-1.5 0-3 1.2-3 3M6 8h3M6 14h3M15 8h3M15 14h3",
play: "M7 5l11 7-11 7V5z",
sun: "M12 7a5 5 0 100 10 5 5 0 000-10zM12 1v2M12 21v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M1 12h2M21 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4",
moon: "M21 12.8A9 9 0 1111.2 3a7 7 0 009.8 9.8z",
more: "M12 6h.01M12 12h.01M12 18h.01",
share: "M4 12v7a1 1 0 001 1h14a1 1 0 001-1v-7M16 6l-4-4-4 4M12 2v13",
pin: "M9 3h6l-1 7 4 3v2H7v-2l4-3-1-7zM12 15v6",
arrowUp: "M12 19V5M5 12l7-7 7 7",
refresh: "M3 12a9 9 0 0115-6.7L21 8M21 4v4h-4M21 12a9 9 0 01-15 6.7L3 16M3 20v-4h4",
layers: "M12 3l9 5-9 5-9-5 9-5zM3 13l9 5 9-5M3 17l9 5 9-5",
spark: "M12 4v6M12 14v6M4 12h6M14 12h6",
clock: "M12 3a9 9 0 100 18 9 9 0 000-18zM12 8v4l3 2",
filter: "M3 5h18l-7 8v6l-4-2v-4z",
expand: "M8 3H3v5M16 3h5v5M3 16v5h5M21 16v5h-5",
drag: "M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01",
mail: "M4 6h16v12H4zM4 7l8 6 8-6",
lock: "M6 11V8a6 6 0 0112 0v3M5 11h14v10H5zM12 15v3",
user: "M12 12a4 4 0 100-8 4 4 0 000 8zM5 20a7 7 0 0114 0",
eye: "M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7zM12 15a3 3 0 100-6 3 3 0 000 6z",
arrowRight: "M5 12h14M13 6l6 6-6 6",
shield: "M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6l8-3zM9 12l2 2 4-4",
minus: "M5 12h14",
maximize: "M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5",
minimize: "M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5",
power: "M12 4v8M7.5 7a7 7 0 109 0",
mic: "M12 3a3 3 0 013 3v6a3 3 0 01-6 0V6a3 3 0 013-3zM5 11a7 7 0 0014 0M12 18v3M8 21h8",
key: "M14 7a4 4 0 11-3.5 6L4 20H2v-2l1-1h2v-2h2l1.5-1.5A4 4 0 0114 7zM15.5 8.5h.01",
gear: "M12 9a3 3 0 100 6 3 3 0 000-6zM19.4 13a7.8 7.8 0 000-2l2-1.5-2-3.4-2.4 1a7.6 7.6 0 00-1.7-1l-.4-2.6H10.7l-.4 2.6a7.6 7.6 0 00-1.7 1l-2.4-1-2 3.4L4.6 11a7.8 7.8 0 000 2l-2 1.5 2 3.4 2.4-1a7.6 7.6 0 001.7 1l.4 2.6h3.5l.4-2.6a7.6 7.6 0 001.7-1l2.4 1 2-3.4z",
};
function Icon({ name, size = 18, stroke = 2, style = {}, className = "" }) {
const d = ICONS[name];
return (
{d.split("M").filter(Boolean).map((seg, i) => )}
);
}
// ---- Connectors ----
const CONNECTORS = {
gworkspace: { label: "Google Workspace", color: "#1A73E8", short: "Workspace" },
m365: { label: "Microsoft 365", color: "#D8503C", short: "M365" },
slack: { label: "Slack", color: "#5A2D6E", short: "Slack" },
github: { label: "GitHub", color: "#24292E", short: "GitHub" },
notion: { label: "Notion", color: "#1F1F1F", short: "Notion" },
discord: { label: "Discord", color: "#5865F2", short: "Discord" },
sheets: { label: "Google Sheets", color: "#1E8E5A", short: "Sheets" },
gmail: { label: "Gmail", color: "#D8503C", short: "Gmail" },
http: { label: "HTTP Request", color: "var(--copper)", short: "HTTP" },
webhook: { label: "Webhook", color: "var(--olive)", short: "Webhook" },
schedule: { label: "Agendamento", color: "var(--olive)", short: "Schedule" },
openai_c: { label: "OpenAI", color: "#0E8C6E", short: "OpenAI" },
gdrive: { label: "Google Drive", color: "#1FA463", short: "Drive" },
gcal: { label: "Google Calendar", color: "#4285F4", short: "Calendar" },
gforms: { label: "Google Forms", color: "#7248B9", short: "Forms" },
teams: { label: "Microsoft Teams", color: "#5059C9", short: "Teams" },
outlook: { label: "Outlook", color: "#0A66C2", short: "Outlook" },
gitlab: { label: "GitLab", color: "#FC6D26", short: "GitLab" },
jira: { label: "Jira", color: "#2684FF", short: "Jira" },
trello: { label: "Trello", color: "#0079BF", short: "Trello" },
asana: { label: "Asana", color: "#F06A6A", short: "Asana" },
hubspot: { label: "HubSpot", color: "#FF7A59", short: "HubSpot" },
salesforce: { label: "Salesforce", color: "#00A1E0", short: "Salesforce" },
stripe: { label: "Stripe", color: "#635BFF", short: "Stripe" },
shopify: { label: "Shopify", color: "#5E8E3E", short: "Shopify" },
telegram: { label: "Telegram", color: "#2AABEE", short: "Telegram" },
whatsapp: { label: "WhatsApp", color: "#25D366", short: "WhatsApp" },
twilio: { label: "Twilio", color: "#F22F46", short: "Twilio" },
airtable: { label: "Airtable", color: "#E5862B", short: "Airtable" },
postgres: { label: "PostgreSQL", color: "#336791", short: "Postgres" },
mysql: { label: "MySQL", color: "#00758F", short: "MySQL" },
mongodb: { label: "MongoDB", color: "#3FA037", short: "MongoDB" },
dropbox: { label: "Dropbox", color: "#0061FF", short: "Dropbox" },
aws: { label: "AWS S3", color: "#E8912D", short: "AWS" },
hf: { label: "Hugging Face", color: "#F4A300", short: "HF" },
};
Object.assign(CONNECTORS, {
http_request: CONNECTORS.http,
slack_webhook: CONNECTORS.slack,
google_sheets_append: CONNECTORS.sheets,
google_drive: CONNECTORS.gdrive,
google_calendar: CONNECTORS.gcal,
microsoft_outlook: CONNECTORS.outlook,
microsoft_teams: CONNECTORS.teams,
microsoft_excel: CONNECTORS.m365,
});
const CGLYPH = {
gworkspace: <> >,
m365: <> >,
slack: <> >,
github: ,
notion: <> >,
discord: ,
sheets: <> >,
gmail: ,
http: ,
webhook: ,
schedule: ,
openai_c: ,
postgres: <> >,
};
Object.assign(CGLYPH, {
http_request: CGLYPH.http,
slack_webhook: CGLYPH.slack,
google_sheets_append: CGLYPH.sheets,
google_drive: CGLYPH.gdrive,
google_calendar: CGLYPH.gcal,
microsoft_outlook: CGLYPH.outlook,
microsoft_teams: CGLYPH.teams,
microsoft_excel: CGLYPH.m365,
});
function Connector({ id, size = 22, tile = true, style = {} }) {
const c = CONNECTORS[id] || CONNECTORS.http;
const glyph = CGLYPH[id];
const inner = glyph ? (
{glyph}
) : (
{(c.short || c.label)[0].toUpperCase()}
);
if (!tile) return inner;
return (
{inner}
);
}
// ---- AI providers ----
const PROVIDERS = {
ollama: { label: "Ollama", sub: "Local/Cloud", model: "gpt-oss:120b", kind: "local", tools: ["web_search"] },
openai: { label: "OpenAI", sub: "Cloud API", model: "gpt-4.1-mini", kind: "cloud", tools: ["web_search"] },
anthropic: { label: "Claude", sub: "Cloud API", model: "claude-sonnet-4-5",kind: "cloud", tools: ["web_search"] },
deepseek: { label: "DeepSeek", sub: "Cloud API", model: "deepseek-chat", kind: "cloud", tools: ["web_search"] },
gemini: { label: "Gemini", sub: "Cloud API", model: "gemini-2.5-flash",kind: "cloud", tools: ["google_search"] },
};
const PGLYPH = {
ollama: ,
openai: ,
anthropic: ,
deepseek: ,
gemini: ,
};
function ProviderMark({ id, size = 18, style = {} }) {
return (
{PGLYPH[id] || PGLYPH.ollama}
);
}
Object.assign(window, { Icon, Connector, ProviderMark, CONNECTORS, PROVIDERS });
/* ===== data.jsx ===== */
// data.jsx — seed content
const NAV = [
{ id: "chats", label: "Chats", icon: "chat" },
{ id: "flows", label: "Flows", icon: "flows" },
{ id: "connect", label: "Conectores", icon: "connect" },
{ id: "runs", label: "Execuções", icon: "runs" },
{ id: "models", label: "Modelos", icon: "models" },
{ id: "agents", label: "Agentes", icon: "agents" },
{ id: "history", label: "Histórico", icon: "history" },
];
// connectors shown in the suggestion filter row
const AVAILABLE_CONNECTORS = [
"webhook", "http", "github", "slack", "gmail", "sheets", "notion",
"gdrive", "gcal", "outlook", "teams", "m365", "hubspot", "trello", "airtable",
];
// chats live in app state (pin / rename / delete)
const SEED_CHATS = [];
const WHEN_ORDER = ["Hoje", "Ontem", "7 dias anteriores", "30 dias anteriores"];
const STARTERS = [
{ icon: "bolt", text: "Monitorar uma API e me avisar quando algo mudar" },
{ icon: "clock", text: "Agendar uma tarefa que roda todo dia de manhã" },
{ icon: "layers", text: "Conectar duas ferramentas e sincronizar dados" },
{ icon: "spark", text: "Resumir novidades e mandar pro meu canal" },
{ icon: "refresh", text: "Sincronizar leads do formulário com o CRM" },
{ icon: "filter", text: "Filtrar e-mails e encaminhar pro responsável" },
];
const CONNECTOR_SUGGESTIONS = {
webhook: [
{ title: "Novo lead → Sheets", desc: "Quando chegar um lead via webhook, adicione uma linha na planilha.", connectors: ["webhook", "sheets"] },
{ title: "Webhook → Notion", desc: "Crie um card no Notion sempre que um formulário for enviado.", connectors: ["webhook", "notion"] },
{ title: "Webhook → WhatsApp", desc: "Dispara uma mensagem no WhatsApp a cada novo evento recebido.", connectors: ["webhook", "whatsapp"] },
],
http: [
{ title: "Monitorar API e alertar no Discord", desc: "A cada 5 min, faz GET em uma URL e avisa no Discord se o status mudar.", connectors: ["schedule", "http", "discord"] },
{ title: "Cotação diária em planilha", desc: "Todo dia às 9h, busca a cotação e registra no Google Sheets.", connectors: ["schedule", "http", "sheets"] },
],
github: [
{ title: "Resumo de PRs abertos no Slack", desc: "Toda manhã, lista os PRs aguardando revisão no canal do time.", connectors: ["schedule", "github", "slack"] },
{ title: "Issue nova → Notion", desc: "Cada issue criada vira uma tarefa priorizada no board.", connectors: ["github", "notion"] },
{ title: "Deploy → aviso no Teams", desc: "Quando um release for publicado, notifica o time no Teams.", connectors: ["github", "teams"] },
],
slack: [
{ title: "Resumo diário no Slack", desc: "Compila as métricas do dia e posta um digest no canal.", connectors: ["schedule", "slack"] },
{ title: "Menção → tarefa", desc: "Quando te marcarem, cria uma tarefa de acompanhamento no Trello.", connectors: ["slack", "trello"] },
],
gmail: [
{ title: "Triagem de e-mails de suporte", desc: "Classifica e-mails recebidos e encaminha pro responsável.", connectors: ["gmail", "notion"] },
{ title: "Anexos → Drive", desc: "Salva automaticamente anexos de faturas no Google Drive.", connectors: ["gmail", "gdrive"] },
],
sheets: [
{ title: "Backup semanal de planilhas", desc: "Toda segunda, duplica e arquiva as planilhas do mês.", connectors: ["schedule", "sheets"] },
{ title: "Linha nova → Slack", desc: "Avisa o time quando uma nova linha for adicionada.", connectors: ["sheets", "slack"] },
],
notion: [
{ title: "Notion → lembrete", desc: "Tarefas que vencem hoje viram lembretes no seu canal.", connectors: ["notion", "slack"] },
{ title: "Página nova → Telegram", desc: "Notifica no Telegram quando uma página for criada no banco.", connectors: ["notion", "telegram"] },
],
discord: [
{ title: "Alerta de incidente", desc: "Quando um serviço cair, posta um alerta com detalhes no Discord.", connectors: ["http", "discord"] },
],
gworkspace: [
{ title: "Evento de calendário → resumo", desc: "Antes de cada reunião, envia uma pauta resumida por e-mail.", connectors: ["gcal", "gmail"] },
],
m365: [
{ title: "Teams → registro", desc: "Arquiva mensagens importantes do Teams numa planilha.", connectors: ["teams", "sheets"] },
],
telegram: [
{ title: "Bot de status diário", desc: "Manda um resumo do sistema no Telegram toda manhã.", connectors: ["schedule", "telegram"] },
],
stripe: [
{ title: "Pagamento → recibo no e-mail", desc: "Cada cobrança aprovada dispara um recibo automático.", connectors: ["stripe", "gmail"] },
{ title: "Reembolso → alerta no Slack", desc: "Avisa o financeiro sempre que houver um reembolso.", connectors: ["stripe", "slack"] },
],
hubspot: [
{ title: "Lead qualificado → tarefa", desc: "Quando um lead esquenta, cria tarefa pro vendedor responsável.", connectors: ["hubspot", "asana"] },
],
gcal: [
{ title: "Reunião nova → Slack", desc: "Posta no canal quando um evento for criado na agenda.", connectors: ["gcal", "slack"] },
],
trello: [
{ title: "Card movido → notificação", desc: "Avisa quando um card chega na coluna 'Concluído'.", connectors: ["trello", "discord"] },
],
airtable: [
{ title: "Registro novo → e-mail", desc: "Dispara um e-mail formatado a cada registro adicionado.", connectors: ["airtable", "gmail"] },
],
salesforce: [
{ title: "Oportunidade ganha → Slack", desc: "Comemora no canal quando um negócio é fechado no Salesforce.", connectors: ["salesforce", "slack"] },
],
shopify: [
{ title: "Pedido novo → planilha", desc: "Registra cada pedido da loja numa planilha de controle.", connectors: ["shopify", "sheets"] },
],
};
// flat pool used for the 4 random suggestions
const SUGGESTION_POOL = Object.values(CONNECTOR_SUGGESTIONS).flat();
function pickRandom(arr, n) {
const copy = [...arr];
for (let i = copy.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [copy[i], copy[j]] = [copy[j], copy[i]]; }
return copy.slice(0, n);
}
// blueprint generated by the AI
const BLUEPRINT = {
name: "Cotação USD/BRL diária", tag: "finanças",
nodes: [
{ id: "n1", kind: "TRIGGER", title: "Todo dia às 9h", sub: "schedule", connector: "schedule" },
{ id: "n2", kind: "ACTION", title: "Buscar cotação USD/BRL", sub: "http.request", connector: "http" },
{ id: "n3", kind: "ACTION", title: "Registrar no Google Sheets", sub: "sheets.append_row", connector: "sheets" },
],
};
// blank workflow (Flows → Novo workflow)
const BLANK_BLUEPRINT = { name: "Novo workflow", tag: "rascunho", blank: true, nodes: [] };
// credential fields requested per connector type (node settings)
const CONN_CREDENTIALS = {
schedule: [
{ k: "freq", label: "Frequência", type: "select", options: ["A cada 5 min", "A cada hora", "Diária", "Semanal", "Mensal"] },
{ k: "time", label: "Horário", ph: "09:00", type: "time" },
{ k: "tz", label: "Fuso horário", ph: "America/Sao_Paulo" },
],
http: [
{ k: "url", label: "URL", ph: "https://api.exemplo.com/cotacao" },
{ k: "method", label: "Método", type: "select", options: ["GET", "POST", "PUT", "DELETE"] },
],
webhook: [
{ k: "path", label: "Caminho do webhook", ph: "/hooks/novo-lead" },
],
sheets: [
{ k: "account", label: "Conta Google", ph: "voce@gmail.com" },
{ k: "sheet", label: "ID da planilha", ph: "1AbC…xyz" },
{ k: "range", label: "Intervalo", ph: "Página1!A:D" },
],
gmail: [{ k: "account", label: "Conta Gmail", ph: "voce@gmail.com" }, { k: "label", label: "Rótulo (opcional)", ph: "Suporte" }],
slack: [{ k: "account", label: "Conta OAuth", ph: "Conecte o Slack em Conectores" }, { k: "channel", label: "Canal", ph: "#geral" }],
discord: [{ k: "account", label: "Conta OAuth", ph: "Conecte o Discord em Conectores" }, { k: "channel", label: "Canal", ph: "#avisos" }],
telegram: [{ k: "account", label: "Conta OAuth", ph: "Conecte o Telegram em Conectores" }, { k: "chat", label: "Chat ID", ph: "@meu_canal" }],
notion: [{ k: "account", label: "Conta OAuth", ph: "Conecte o Notion em Conectores" }, { k: "db", label: "ID do banco de dados", ph: "a1b2c3…" }],
github: [{ k: "account", label: "Conta OAuth", ph: "Conecte o GitHub em Conectores" }, { k: "repo", label: "Repositório", ph: "org/projeto" }],
stripe: [{ k: "account", label: "Conta OAuth", ph: "Conecte o Stripe em Conectores" }],
gdrive: [{ k: "account", label: "Conta Google", ph: "Conecte o Google Drive em Conectores" }, { k: "folder", label: "Pasta", ph: "ID ou caminho da pasta" }],
gcal: [{ k: "account", label: "Conta Google", ph: "Conecte o Google Calendar em Conectores" }, { k: "calendar", label: "Agenda", ph: "primary" }],
outlook: [{ k: "account", label: "Conta Microsoft", ph: "Conecte o Outlook em Conectores" }, { k: "folder", label: "Pasta", ph: "Inbox" }],
teams: [{ k: "account", label: "Conta Microsoft", ph: "Conecte o Teams em Conectores" }, { k: "channel", label: "Canal", ph: "Time / Canal" }],
m365: [{ k: "account", label: "Conta Microsoft", ph: "Conecte o Microsoft 365 em Conectores" }, { k: "workbook", label: "Workbook", ph: "Arquivo Excel" }],
hubspot: [{ k: "account", label: "Conta OAuth", ph: "Conecte o HubSpot em Conectores" }, { k: "pipeline", label: "Pipeline", ph: "default" }],
airtable: [{ k: "account", label: "Conta OAuth", ph: "Conecte o Airtable em Conectores" }, { k: "base", label: "Base", ph: "app..." }],
trello: [{ k: "account", label: "Conta OAuth", ph: "Conecte o Trello em Conectores" }, { k: "board", label: "Board", ph: "Board ID" }],
};
const DEFAULT_CREDENTIALS = [{ k: "account", label: "Conta OAuth", ph: "Conecte uma conta em Conectores" }];
// ---- Flows list ----
const FLOWS = [
{ id: "f1", name: "Cotação USD/BRL diária", tag: "finanças", status: "ativo", nodes: 3, connectors: ["schedule", "http", "sheets"], lastRun: "há 2 h", runs: 142 },
{ id: "f2", name: "Resumo de PRs no Slack", tag: "dev", status: "ativo", nodes: 4, connectors: ["schedule", "github", "slack"], lastRun: "há 5 h", runs: 88 },
{ id: "f3", name: "Novo lead para CRM no Notion", tag: "vendas", status: "rascunho", nodes: 3, connectors: ["webhook", "notion"], lastRun: "Nunca", runs: 0 },
{ id: "f4", name: "Alerta de API fora do ar", tag: "infra", status: "pausado", nodes: 3, connectors: ["http", "discord"], lastRun: "há 3 d", runs: 311 },
{ id: "f5", name: "Triagem de e-mails de suporte", tag: "suporte", status: "ativo", nodes: 5, connectors: ["gmail", "notion"], lastRun: "há 12 min", runs: 506 },
{ id: "f6", name: "Cobrança Stripe → planilha", tag: "finanças", status: "ativo", nodes: 4, connectors: ["stripe", "sheets"], lastRun: "há 1 h", runs: 73 },
];
// ---- Runs / executions ----
const RUNS = [
{ id: "r1", flow: "Triagem de e-mails de suporte", status: "sucesso", when: "há 12 min", dur: "1,2 s", trigger: "gmail" },
{ id: "r2", flow: "Cotação USD/BRL diária", status: "sucesso", when: "há 2 h", dur: "0,8 s", trigger: "schedule" },
{ id: "r3", flow: "Cobrança Stripe → planilha", status: "erro", when: "há 1 h", dur: "2,4 s", trigger: "stripe" },
{ id: "r4", flow: "Resumo de PRs no Slack", status: "sucesso", when: "há 5 h", dur: "1,0 s", trigger: "schedule" },
{ id: "r5", flow: "Alerta de API fora do ar", status: "em execução", when: "agora", dur: "em curso", trigger: "http" },
{ id: "r6", flow: "Triagem de e-mails de suporte", status: "sucesso", when: "há 6 h", dur: "1,1 s", trigger: "gmail" },
];
// ---- Skills catalog (agents) ----
const SKILLS_CATALOG = [
{ id: "build_flow", label: "Gerar workflow", icon: "flows", desc: "Cria fluxos de automação a partir de linguagem natural." },
{ id: "web_search", label: "Buscar na web", icon: "globe", desc: "Pesquisa informações atualizadas na internet." },
{ id: "classify", label: "Classificar texto", icon: "tag", desc: "Rotula e categoriza mensagens e documentos." },
{ id: "summarize", label: "Resumir conteúdo", icon: "layers", desc: "Condensa textos longos em resumos objetivos." },
{ id: "extract", label: "Extrair dados", icon: "brackets", desc: "Identifica e estrutura campos de textos e arquivos." },
{ id: "route", label: "Rotear & encaminhar", icon: "share", desc: "Direciona itens ao responsável ou canal certo." },
{ id: "sql", label: "Consultar banco", icon: "models", desc: "Lê e analisa dados em bancos conectados." },
{ id: "notify", label: "Notificar", icon: "bolt", desc: "Envia avisos em canais como Slack, e-mail e Telegram." },
{ id: "schedule", label: "Agendar tarefas", icon: "clock", desc: "Dispara ações em horários ou intervalos definidos." },
{ id: "reply", label: "Responder mensagens", icon: "chat", desc: "Redige e envia respostas em nome do usuário." },
];
// ---- Agents ----
const AGENTS = [
{ id: "a1", name: "Construtor de Workflows", desc: "Transforma pedidos em linguagem natural em fluxos prontos.", model: "claude-sonnet-4.5", status: "ativo",
skillIds: ["build_flow", "extract", "schedule", "notify", "summarize", "route"],
instructions: "Interprete o pedido do usuário, identifique gatilhos e ações, e monte o workflow conectando as ferramentas disponíveis." },
{ id: "a2", name: "Triador de Suporte", desc: "Lê, classifica e roteia tickets recebidos por e-mail.", model: "gpt-4o", status: "ativo",
skillIds: ["classify", "route", "reply"],
instructions: "Classifique cada e-mail por urgência e tema, e encaminhe ao responsável certo com um resumo." },
{ id: "a3", name: "Analista de Dados", desc: "Resume planilhas e responde perguntas sobre os números.", model: "gemma3:4b", status: "pausado",
skillIds: ["sql", "summarize", "extract", "notify"],
instructions: "Consulte os dados conectados, gere análises e responda perguntas de negócio de forma objetiva." },
];
// ---- Models (providers) ----
const MODEL_CATALOG = [
{ provider: "ollama", connected: true, models: ["gpt-oss:120b", "gpt-oss:20b", "gemma3:27b", "qwen3-next:80b"],
webSearchModels: ["gpt-oss:120b", "gpt-oss:20b", "gemma3:27b", "qwen3-next:80b"],
thinkingModels: ["gpt-oss:120b", "gpt-oss:20b", "gpt-oss:120b-cloud", "gpt-oss:20b-cloud", "qwen3-next:80b"] },
{ provider: "openai", connected: true, models: ["gpt-4.1-mini", "gpt-4.1", "gpt-4o-mini", "gpt-4o"],
webSearchModels: ["gpt-4.1-mini", "gpt-4.1", "gpt-4o-mini", "gpt-4o"],
thinkingModels: ["o3-mini"] },
{ provider: "anthropic", connected: false, models: ["claude-sonnet-4-5", "claude-opus-4-1", "claude-haiku-4-5"],
webSearchModels: ["claude-sonnet-4-5", "claude-opus-4-1", "claude-haiku-4-5"],
thinkingModels: ["claude-sonnet-4-5", "claude-opus-4-1"] },
{ provider: "deepseek", connected: false, models: ["deepseek-chat", "deepseek-reasoner"],
webSearchModels: ["deepseek-chat", "deepseek-reasoner"],
thinkingModels: ["deepseek-reasoner"] },
{ provider: "gemini", connected: false, models: ["gemini-2.5-flash", "gemini-2.5-pro"],
webSearchModels: ["gemini-2.5-flash", "gemini-2.5-pro"],
thinkingModels: ["gemini-2.5-flash", "gemini-2.5-pro"] },
];
// ---- History feed ----
const HISTORY = [
{ id: "h1", icon: "flows", text: "Workflow “Cotação USD/BRL diária” foi publicado", when: "há 2 h" },
{ id: "h2", icon: "runs", text: "Execução com erro em “Cobrança Stripe → planilha”", when: "há 1 h", bad: true },
{ id: "h3", icon: "connect", text: "Conector Stripe conectado", when: "ontem" },
{ id: "h4", icon: "chat", text: "Nova conversa: “Triagem de e-mails de suporte”", when: "ontem" },
{ id: "h5", icon: "agents", text: "Agente “Triador de Suporte” ativado", when: "há 3 d" },
{ id: "h6", icon: "models", text: "Modelo local llama3.1:8b adicionado", when: "há 4 d" },
];
// ---- Connector categories (Connect page) ----
const CONNECTOR_CATEGORIES = [
{ label: "Comunicação", ids: ["slack", "discord", "telegram", "whatsapp", "teams", "gmail", "outlook"] },
{ label: "Produtividade", ids: ["notion", "sheets", "gdrive", "gcal", "gforms", "airtable", "trello", "asana"] },
{ label: "Dev & DevOps", ids: ["github", "gitlab", "jira", "http", "webhook", "aws"] },
{ label: "CRM & Vendas", ids: ["hubspot", "salesforce", "stripe", "shopify"] },
{ label: "Dados", ids: ["postgres", "mysql", "mongodb", "dropbox"] },
];
const CONNECTED_IDS = [];
// ---- Plans ----
const PLANS = [
{ id: "free", name: "Free", price: "R$ 0", per: "/mês", tagline: "Para experimentar", current: true,
features: ["1 workflow ativo", "100 execuções/mês", "Somente modelos locais (Ollama)", "3 conectores", "15 buscas online/mês", "Histórico de 7 dias"] },
{ id: "basic", name: "Basic", price: "R$ 49", per: "/mês", tagline: "Para uso pessoal", current: false,
features: ["10 workflows ativos", "5 mil execuções/mês", "500 mensagens de IA na nuvem/mês", "OpenAI, Claude, Gemini e DeepSeek inclusos", "Conectores ilimitados", "Histórico de 30 dias"] },
{ id: "pro", name: "Pro", price: "R$ 199", per: "/mês", tagline: "Para times pequenos", current: false, popular: true,
features: ["100 workflows ativos", "50 mil execuções/mês", "2.000 mensagens de IA na nuvem/mês", "Modelos premium via sua chave (BYOK)", "Agentes personalizados", "1.000 buscas online/mês", "Suporte prioritário"] },
{ id: "proplus", name: "Pro+", price: "R$ 599", per: "/mês", tagline: "Para empresas", current: false,
features: ["Tudo do Pro, limites ampliados", "5.000 mensagens de IA na nuvem/mês", "250 mil execuções/mês", "SSO & controle de acesso", "Logs de auditoria", "Gerente de conta dedicado"] },
];
// mapeia ids de icone do front <-> ids de conector do backend (os conectaveis)
const CONNECTOR_BACKEND = {
schedule: "schedule", webhook: "webhook", http: "http_request",
slack: "slack_webhook", sheets: "google_sheets_append", gmail: "gmail",
gdrive: "google_drive", gcal: "google_calendar",
outlook: "microsoft_outlook", teams: "microsoft_teams", m365: "microsoft_excel",
notion: "notion", github: "github", hubspot: "hubspot", airtable: "airtable", trello: "trello",
stripe: "stripe", discord: "discord", telegram: "telegram",
whatsapp: "whatsapp", twilio: "twilio",
// Conector de banco de dados (PostgreSQL, somente-leitura): card "PostgreSQL"
// da categoria Dados passa a ser conectavel de verdade.
postgres: "database",
};
const CONNECTOR_FRONT = Object.fromEntries(Object.entries(CONNECTOR_BACKEND).map(([f, b]) => [b, f]));
function frontConnectorId(id) { return CONNECTOR_FRONT[id] || id; }
function backendConnectorId(id) { return CONNECTOR_BACKEND[id] || id; }
Object.assign(window, {
NAV, AVAILABLE_CONNECTORS, SEED_CHATS, WHEN_ORDER, STARTERS,
CONNECTOR_SUGGESTIONS, SUGGESTION_POOL, pickRandom, BLUEPRINT, BLANK_BLUEPRINT,
CONN_CREDENTIALS, DEFAULT_CREDENTIALS,
FLOWS, RUNS, AGENTS, SKILLS_CATALOG, MODEL_CATALOG, HISTORY, CONNECTOR_CATEGORIES, CONNECTED_IDS, PLANS,
CONNECTOR_BACKEND, CONNECTOR_FRONT, frontConnectorId, backendConnectorId,
});
/* ===== ui.jsx ===== */
// ui.jsx — shared UI: IconRail, ChatListPanel, ProviderDropdown, SuggestionCard,
// StarterChip, MessageBubble, Composer, Avatar
const { useState, useRef, useEffect } = React;
function Avatar({ name = "Kobar", size = 28 }) {
const initials = (name || "K").split(" ").map((w) => w[0]).filter(Boolean).slice(0, 2).join("").toUpperCase() || "K";
return (
{initials}
);
}
function IconRail({ active, onNav, onHome, dark, expanded, onToggleExpand, onUpgrade, user, onLogout }) {
const [acct, setAcct] = useState(false);
const uName = (user && user.name) || "Minha conta";
const uEmail = (user && user.email) || "";
const uPlan = (user && user.org ? user.org + " · " : "") + ((user && user.plan) || "Free");
const acctRef = useRef(null);
useEffect(() => {
if (!acct) return;
const h = (e) => { if (acctRef.current && !acctRef.current.contains(e.target)) setAcct(false); };
document.addEventListener("mousedown", h);
return () => document.removeEventListener("mousedown", h);
}, [acct]);
return (
{expanded && Kobar Flows }
{NAV.map((n) => (
onNav(n.id)} data-tip={expanded ? "" : n.label}>
{expanded && {n.label} }
))}
setAcct((a) => !a)} data-tip={expanded ? "" : uName}>
{expanded && {uName} {uPlan} }
{expanded && }
{acct && (
{ onUpgrade(); setAcct(false); }}>
Fazer upgrade de plano
{ onNav("settings"); setAcct(false); }}> Configurações
{ onNav("connect"); setAcct(false); }}> Conectores
{ setAcct(false); onLogout && onLogout(); }}> Sair
)}
);
}
function ConnectorDots({ ids, size = 16 }) {
return (
{ids.slice(0, 3).map((id, i) => (
))}
);
}
function ChatRow({ c, active, onSelect, onPin, onRename, onDelete }) {
const [menu, setMenu] = useState(false);
const [editing, setEditing] = useState(false);
const [val, setVal] = useState(c.title);
const ref = useRef(null);
useEffect(() => {
if (!menu) return;
const h = (e) => { if (ref.current && !ref.current.contains(e.target)) setMenu(false); };
document.addEventListener("mousedown", h);
return () => document.removeEventListener("mousedown", h);
}, [menu]);
const submit = () => { const v = val.trim(); if (v) onRename(c.id, v); setEditing(false); };
return (
{ if (!editing) onSelect(c.id); }}>
{editing ? (
setVal(e.target.value)} onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => { if (e.key === "Enter") submit(); if (e.key === "Escape") setEditing(false); }}
onBlur={submit} />
) : (
{c.title}
)}
{c.pinned && !editing &&
}
{ e.stopPropagation(); setMenu((m) => !m); }} data-tip2="Opções">
{menu && (
e.stopPropagation()}>
{ onPin(c.id); setMenu(false); }}> {c.pinned ? "Desafixar" : "Fixar"}
{ setEditing(true); setMenu(false); }}> Renomear
{ onDelete(c.id); setMenu(false); }}> Excluir
)}
);
}
function ChatListPanel({ chats, activeChat, onSelectChat, onNew, onPin, onRename, onDelete, onDeleteAll, onNav, onUpgrade, collapsed, onToggle, planName = "Pro" }) {
const [q, setQ] = useState("");
const [confirmAll, setConfirmAll] = useState(false);
if (collapsed) return null;
const filtered = chats.filter((c) => c.title.toLowerCase().includes(q.toLowerCase()));
const pinned = filtered.filter((c) => c.pinned);
const groups = WHEN_ORDER.map((w) => ({ label: w, chats: filtered.filter((c) => !c.pinned && c.when === w) })).filter((g) => g.chats.length);
const rowProps = { active: activeChat, onSelect: onSelectChat, onPin, onRename, onDelete };
return (
Conversas
{chats.length > 0 && (
setConfirmAll(true)} data-tip="Apagar todas as conversas" aria-label="Apagar todas as conversas">
)}
{confirmAll && (
setConfirmAll(false)}>
e.stopPropagation()}>
Apagar todas as conversas?
Esta ação não pode ser desfeita.
As {chats.length} conversas e suas mensagens serão removidas permanentemente do seu workspace.
setConfirmAll(false)}>Cancelar
{ setConfirmAll(false); onDeleteAll && onDeleteAll(); }}>
Apagar tudo
)}
Novo chat
setQ(e.target.value)} placeholder="Buscar conversas" />
{pinned.length > 0 && (
Fixados
{pinned.map((c) =>
)}
)}
{groups.map((g) => (
{g.label}
{g.chats.map((c) =>
)}
))}
{filtered.length === 0 &&
Nenhuma conversa encontrada.
}
);
}
function providerModel(id, modelChoice) {
return (modelChoice && modelChoice[id]) || (PROVIDERS[id] && PROVIDERS[id].model) || id;
}
function ProviderDropdown({ provider, onChange, dark, modelChoice, onManage }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
const h = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener("mousedown", h);
return () => document.removeEventListener("mousedown", h);
}, []);
const p = PROVIDERS[provider];
// So mostra provedores com conexao ativa (status connected na /api/models).
// window.MODEL_CATALOG ja vem filtrado: non-admin = so connected; admin/demo = todos.
const catalog = (typeof window !== "undefined" && Array.isArray(window.MODEL_CATALOG)) ? window.MODEL_CATALOG : [];
const connectedIds = catalog
.filter((c) => c.connected && PROVIDERS[c.provider] && (c.models ? c.models.length > 0 : true))
.map((c) => c.provider);
const fallbackIds = ["ollama", "openai", "anthropic", "deepseek", "gemini"];
const ids = connectedIds.length ? connectedIds : fallbackIds;
const localIds = ids.filter((id) => PROVIDERS[id] && PROVIDERS[id].kind === "local");
const cloudIds = ids.filter((id) => PROVIDERS[id] && PROVIDERS[id].kind === "cloud");
return (
setOpen((o) => !o)}>
{p.label}
{providerModel(provider, modelChoice)}
{open && (
{localIds.length > 0 &&
Local
}
{localIds.map((id) => (
{ onChange(id); setOpen(false); }} />
))}
{cloudIds.length > 0 && Cloud API
}
{cloudIds.map((id) => (
{ onChange(id); setOpen(false); }} />
))}
{ setOpen(false); onManage && onManage(); }}> Gerenciar provedores e modelos
)}
);
}
function ProviderItem({ id, active, onPick, modelChoice }) {
const p = PROVIDERS[id];
const model = providerModel(id, modelChoice);
return (
{p.label}
{model}
{model || (p.kind === "local" ? "local" : "Cloud API")}
{active && }
);
}
function StarterChip({ icon, text, onClick }) {
return (
{text}
);
}
function SuggestionCard({ s, onClick }) {
return (
{s.connectors.map((id, i) => )}
{s.title}
{s.desc}
);
}
// Bloco de conectores necessarios numa resposta da IA (clicaveis -> abrem a conexao).
function MessageConnections({ msg, connectedIds, onConnect, onOpenFlow }) {
const conns = msg.connections || [];
const approvals = (msg.actions || []).filter((action) => action.type === "confirm_tool_approval" && action.approvalId);
if (!conns.length && !approvals.length) return null;
const isConnected = (id) => (msg.connectedOverride && msg.connectedOverride[id]) || (connectedIds && connectedIds.has(id));
const needsReconnect = (rc) => rc.status === "missing" && isConnected(rc.connectorId);
const isOn = (rc) => isConnected(rc.connectorId) && !needsReconnect(rc);
const allOn = conns.every((rc) => isOn(rc));
const anyReconnect = conns.some((rc) => needsReconnect(rc));
return (
{!!conns.length && (
<>
{allOn ? "Tudo conectado — pronto para abrir o fluxo." : anyReconnect ? "Reconecte as ferramentas para atualizar permissões:" : "Conecte as ferramentas para concluir:"}
{conns.map((rc) => {
const on = isOn(rc);
const reconnect = needsReconnect(rc);
return (
{rc.label}
{on ? (
conectado
) : (
onConnect && onConnect(rc.connectorId, { forceReconnect: reconnect })}>
{reconnect ? "Reconectar" : "Conectar"}
)}
);
})}
>
)}
{allOn && msg.workflow && (
onOpenFlow && onOpenFlow(msg.workflow)}>
Abrir no editor de workflows seu fluxo está pronto para revisar
)}
);
}
function MessageApprovals({ approvals }) {
const [state, setState] = useState({});
if (!approvals.length) return null;
const confirm = async (approval) => {
setState((s) => ({ ...s, [approval.approvalId]: "running" }));
try {
await kbApprovals.confirm(approval.approvalId);
setState((s) => ({ ...s, [approval.approvalId]: "done" }));
} catch (err) {
setState((s) => ({ ...s, [approval.approvalId]: (err && err.message) || "erro" }));
}
};
return (
{approvals.map((approval) => {
const status = state[approval.approvalId] || "";
const done = status === "done";
const running = status === "running";
return (
{approval.label || "Confirmar ação"}
{done ? (
executado
) : (
confirm(approval)}>
{running ? "Executando..." : "Confirmar"}
)}
);
})}
);
}
function renderInlineMarkdown(text) {
const parts = [];
const re = /(\*\*([^*]+)\*\*)|\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g;
let last = 0, m;
while ((m = re.exec(text))) {
if (m.index > last) parts.push(text.slice(last, m.index));
if (m[2]) parts.push({m[2]} );
else if (m[3] && m[4]) parts.push({m[3]} );
last = re.lastIndex;
}
if (last < text.length) parts.push(text.slice(last));
return parts;
}
function MarkdownTable({ lines }) {
const rows = lines
.filter((line) => !/^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?\s*$/.test(line))
.map((line) => line.replace(/^\||\|$/g, "").split("|").map((cell) => cell.trim()));
if (!rows.length) return null;
const [head, ...body] = rows;
return (
{head.map((cell, i) => {renderInlineMarkdown(cell)} )}
{body.map((row, i) => {row.map((cell, j) => {renderInlineMarkdown(cell)} )} )}
);
}
function decodeChartPayload(payload) {
try {
const json = decodeURIComponent(escape(window.atob(payload)));
const chart = JSON.parse(json);
if (!chart || !Array.isArray(chart.data)) return null;
const data = chart.data
.map((item) => ({ label: String(item.label || ""), value: Number(item.value) }))
.filter((item) => item.label && Number.isFinite(item.value));
if (!data.length) return null;
return { ...chart, data };
} catch (e) {
try {
const chart = JSON.parse(window.atob(payload));
return chart && Array.isArray(chart.data) ? chart : null;
} catch (_) {
return null;
}
}
}
function splitMessageCharts(text) {
const charts = [];
const clean = String(text || "").replace(/\n?\[\[KOBAR_CHART:([A-Za-z0-9+/=]+)\]\]\s*/g, (_, payload) => {
const chart = decodeChartPayload(payload);
if (chart) charts.push(chart);
return "";
}).trim();
return { clean, charts };
}
function formatChartValue(value) {
return new Intl.NumberFormat("pt-BR", { maximumFractionDigits: 2 }).format(value);
}
const CHART_PALETTE = ["#6B7B3A", "#C56B3E", "#3E7C8C", "#B0893A", "#8A5A9E", "#4C8C5A", "#C0533F", "#7A6FB0"];
// Detecta rótulos temporais/sequenciais (datas, meses, semanas, anos) — nesses casos
// a ORDEM importa e NAO deve ser reordenada por valor.
function looksTemporal(data) {
const labels = data.map((d) => String(d.label || "").toLowerCase().trim());
const monthRe = /^(jan|fev|mar|abr|mai|jun|jul|ago|set|out|nov|dez|q[1-4]|s\d|sem|semana|dia|seg|ter|qua|qui|sex|sab|dom)/;
const dateRe = /\d{1,4}[/\-.]\d{1,2}([/\-.]\d{1,4})?|\b(19|20)\d{2}\b/;
const hits = labels.filter((l) => monthRe.test(l) || dateRe.test(l)).length;
return hits >= Math.max(2, Math.ceil(labels.length * 0.6));
}
// Gráfico de LINHA/ÁREA em SVG (escala Y de min..max para mostrar variação real, ex.: câmbio).
// Mostra o rótulo de valor em cada ponto.
function ChartLineSVG({ data, area, color }) {
const W = 600, H = 240, padL = 52, padR = 18, padT = 30, padB = 30;
const vals = data.map((d) => d.value);
let min = Math.min(...vals), max = Math.max(...vals);
if (!Number.isFinite(min) || !Number.isFinite(max)) { min = 0; max = 1; }
if (min === max) { min -= Math.abs(min || 1) * 0.1 || 1; max += Math.abs(max || 1) * 0.1 || 1; }
const span = max - min || 1;
const innerW = W - padL - padR, innerH = H - padT - padB;
const x = (i) => padL + (data.length <= 1 ? innerW / 2 : (i / (data.length - 1)) * innerW);
const y = (v) => padT + (1 - (v - min) / span) * innerH;
const linePts = data.map((d, i) => `${x(i)},${y(d.value)}`).join(" ");
const areaPts = `${padL},${padT + innerH} ${linePts} ${x(data.length - 1)},${padT + innerH}`;
const ticks = [min, min + span / 2, max];
const stroke = color || CHART_PALETTE[0];
return (
{ticks.map((t, i) => {
const yy = y(t);
return (
{formatChartValue(t)}
);
})}
{area && }
{data.map((d, i) => {
const px = x(i), py = y(d.value);
const anchor = i === 0 ? "start" : i === data.length - 1 ? "end" : "middle";
const near = py - min < (max - min) * 0.18 ? py + 16 : py - 9; // se perto do topo, rótulo abaixo
return (
{formatChartValue(d.value)}
{String(d.label).slice(0, 8)}
);
})}
);
}
// Gráfico de PIZZA/DONUT em SVG real. Fatias ordenadas de MAIOR para menor; rótulo de
// porcentagem na fatia (quando cabe) e valor+% na legenda.
function ChartPieSVG({ data, donut }) {
const sorted = [...data].sort((a, b) => Math.abs(b.value) - Math.abs(a.value));
const total = sorted.reduce((s, d) => s + Math.abs(d.value), 0) || 1;
const cx = 95, cy = 95, r = 86, rl = donut ? 66 : r * 0.62;
let a0 = -Math.PI / 2;
const slices = sorted.map((d, i) => {
const frac = Math.abs(d.value) / total;
const a1 = a0 + frac * 2 * Math.PI;
const mid = (a0 + a1) / 2;
const large = frac > 0.5 ? 1 : 0;
const x0 = cx + r * Math.cos(a0), y0 = cy + r * Math.sin(a0);
const x1 = cx + r * Math.cos(a1), y1 = cy + r * Math.sin(a1);
const path = frac >= 0.9999
? `M${cx - r},${cy} A${r},${r} 0 1 1 ${cx + r},${cy} A${r},${r} 0 1 1 ${cx - r},${cy} Z`
: `M${cx},${cy} L${x0},${y0} A${r},${r} 0 ${large} 1 ${x1},${y1} Z`;
a0 = a1;
return { path, color: CHART_PALETTE[i % CHART_PALETTE.length], frac, label: d.label, value: d.value, lx: cx + rl * Math.cos(mid), ly: cy + rl * Math.sin(mid) };
});
return (
{slices.map((s, i) => )}
{donut && }
{slices.filter((s) => s.frac >= 0.08).map((s, i) => (
{Math.round(s.frac * 100)}%
))}
{slices.map((s, i) => (
{s.label}
{formatChartValue(s.value)} · {Math.round(s.frac * 100)}%
))}
);
}
function ChatChart({ chart }) {
const data = (chart.data || []).slice(0, 24);
const max = Math.max(...data.map((item) => Math.abs(item.value)), 1);
const type = chart.type;
return (
{chart.title || "Gráfico"}
{(chart.xLabel || chart.yLabel) && {[chart.xLabel, chart.yLabel].filter(Boolean).join(" · ")} }
{type === "kpi" ? (
{data.map((item, index) => (
{item.label}
{formatChartValue(item.value)}
))}
) : type === "table" ? (
Item Valor
{data.map((item, index) => {item.label} {formatChartValue(item.value)} )}
) : type === "line" || type === "area" ? (
) : type === "pie" || type === "donut" ? (
) : (
(() => {
// Barras de categorias: ordena MAIOR->menor para leitura. Series temporais
// (datas/meses) preservam a ordem cronologica.
const barData = looksTemporal(data) ? data : [...data].sort((a, b) => Math.abs(b.value) - Math.abs(a.value));
return (
{barData.map((item, index) => {
const pct = Math.max(4, Math.round((Math.abs(item.value) / max) * 100));
return (
{item.label}
{formatChartValue(item.value)}
);
})}
);
})()
)}
);
}
// ---- Dashboards no chat (KPIs, graficos, tabelas, texto, botoes) ----
const DASH_WIDTH = { quarter: "1 1 160px", third: "1 1 220px", half: "1 1 300px", full: "1 1 100%" };
function dashChartToChatChart(w) {
const series = Array.isArray(w.series) ? w.series : [];
const first = series[0] || { points: [] };
const points = (first.points || []).map((p) => ({ label: p.label, value: p.value }));
// ChatChart agora desenha bar/line/area/pie/donut; scatter/heatmap caem em bar.
const map = { scatter: "bar", heatmap: "bar" };
const type = map[w.chartType] || (["bar", "line", "area", "pie", "donut"].includes(w.chartType) ? w.chartType : "bar");
return { type, title: w.title || "Gráfico", xLabel: w.xLabel, yLabel: w.yLabel, data: points };
}
function DashboardWidget({ w, onConnect, onOpenFlow }) {
const flex = DASH_WIDTH[w.width] || DASH_WIDTH.full;
const wrap = (child) => {child}
;
if (w.type === "kpi") {
const up = typeof w.delta === "number" ? w.delta >= 0 : null;
return wrap(
{w.title}
{w.value}{w.unit ? {w.unit} : null}
{typeof w.delta === "number" && (
{up ? "▲" : "▼"} {Math.abs(w.delta)}{w.deltaLabel ? ` ${w.deltaLabel}` : "%"}
)}
{w.hint && {w.hint} }
);
}
if (w.type === "chart") return wrap( );
if (w.type === "table") {
return wrap(
{w.title &&
{w.title} }
{(w.headers || []).map((h, i) => {h} )}
{(w.rows || []).map((row, i) => {(row || []).map((c, j) => {String(c)} )} )}
);
}
if (w.type === "text") {
return wrap(
{w.title &&
{w.title} }
{w.body &&
}
{Array.isArray(w.bullets) && w.bullets.map((b, i) =>
{renderInlineMarkdown(b)}
)}
);
}
if (w.type === "buttons") {
const run = (a) => {
if (!a) return;
if (a.kind === "connect_connector" && onConnect) onConnect(a.connectorId);
else if (a.kind === "open_workflow" && onOpenFlow) onOpenFlow(a.workflowId);
else if (a.kind === "open_link" && a.url) window.open(a.url, "_blank", "noopener");
else if (a.kind === "send_message" && window.__kbSend) window.__kbSend(a.message);
};
return wrap(
{(w.buttons || []).map((b, i) => (
run(b.action)}>{b.label}
))}
);
}
if (w.type === "divider") return wrap({w.label || ""}
);
return null;
}
function DashboardBlock({ dashboard, onConnect, onOpenFlow }) {
const widgets = (dashboard && Array.isArray(dashboard.widgets)) ? dashboard.widgets : [];
if (!widgets.length) return null;
return (
{dashboard.title || "Dashboard"}
{dashboard.subtitle && {dashboard.subtitle} }
{widgets.map((w, i) => )}
);
}
function MessageDashboards({ dashboards, onConnect, onOpenFlow }) {
const all = Array.isArray(dashboards) ? dashboards : [];
if (!all.length) return null;
return <>{all.map((dash, i) => )}>;
}
function RichMessageText({ text }) {
const parsed = splitMessageCharts(text);
const lines = String(parsed.clean || "").split("\n");
const out = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (!line.trim()) { out.push(
); continue; }
if (/^\s*\|.+\|\s*$/.test(line)) {
const table = [];
while (i < lines.length && /^\s*\|.+\|\s*$/.test(lines[i])) table.push(lines[i++]);
i--;
out.push( );
continue;
}
const heading = line.match(/^(#{2,3})\s+(.+)$/);
if (heading) {
const Tag = heading[1].length === 2 ? "h3" : "h4";
out.push({renderInlineMarkdown(heading[2])} );
continue;
}
const bullet = line.match(/^\s*[-•]\s+(.+)$/);
if (bullet) { out.push({renderInlineMarkdown(bullet[1])}
); continue; }
const numbered = line.match(/^\s*(\d+)\.\s+(.+)$/);
if (numbered) { out.push({numbered[1]} {renderInlineMarkdown(numbered[2])}
); continue; }
out.push({renderInlineMarkdown(line)}
);
}
return (
<>
{out}
>
);
}
function MessageCharts({ text, charts }) {
const parsed = splitMessageCharts(text);
const explicit = Array.isArray(charts) ? charts : [];
const all = [...explicit, ...parsed.charts];
if (!all.length) return null;
return <>{all.map((chart, i) => )}>;
}
function MessageBubble({ msg, connectedIds, onConnect, onOpenFlow }) {
if (msg.role === "user") {
const atts = msg.attachments || [];
return (
{atts.length > 0 && (
{atts.map((a, i) => (
a.kind === "image" && a.url ? (
) : a.kind === "audio" && a.url ? (
) : (
{a.name}
)
))}
)}
{msg.text &&
{msg.text}
}
);
}
return (
{msg.thinking ? (
{msg.thinkingText || "Pensando…"}
) : (
{msg.cta && (
{msg.cta.title}
{msg.cta.sub}
)}
)}
{msg.via &&
via {msg.via} }
);
}
Object.assign(window, {
Avatar, IconRail, ChatListPanel, ProviderDropdown, ConnectorDots,
StarterChip, SuggestionCard, MessageBubble, MessageConnections, RichMessageText,
});
/* ===== api.jsx ===== */
// api.jsx — cliente de sessao + auth para o protótipo (sem bundler).
// Fala com os endpoints /api/auth/* e guarda a sessao no localStorage.
// Exporta para window: kbApi, kbAuth.
const KB_SESSION_KEY = "kb_session";
const kbApi = {
getSession() {
try {
const raw = window.localStorage.getItem(KB_SESSION_KEY);
return raw ? JSON.parse(raw) : null;
} catch (e) {
return null;
}
},
setSession(session, { persist = true } = {}) {
if (!session) return;
try {
// "Manter conectado" desligado: nao persiste o refresh_token entre sessoes.
const toStore = persist ? session : { ...session, refreshToken: null };
window.localStorage.setItem(KB_SESSION_KEY, JSON.stringify(toStore));
} catch (e) {
/* storage indisponivel — segue em memoria */
}
},
clearSession() {
try { window.localStorage.removeItem(KB_SESSION_KEY); } catch (e) {}
},
token() {
const s = kbApi.getSession();
return s && s.accessToken ? s.accessToken : null;
},
// fetch autenticado com auto-refresh unico em 401.
async authFetch(path, opts = {}, _retried = false) {
const session = kbApi.getSession();
const headers = Object.assign({}, opts.headers || {});
if (session && session.accessToken) headers["Authorization"] = "Bearer " + session.accessToken;
if (opts.body && !headers["Content-Type"]) headers["Content-Type"] = "application/json";
const res = await fetch(path, Object.assign({}, opts, { headers }));
if (res.status === 401 && !_retried && session && session.refreshToken) {
const refreshed = await kbApi.refresh(session.refreshToken);
if (refreshed) return kbApi.authFetch(path, opts, true);
}
return res;
},
async refresh(refreshToken) {
try {
const res = await fetch("/api/auth/refresh", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refreshToken })
});
if (!res.ok) { kbApi.clearSession(); return null; }
const data = await res.json();
if (data && data.session) { kbApi.setSession(data.session); return data.session; }
return null;
} catch (e) {
return null;
}
}
};
async function postJson(path, body) {
const res = await fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body || {})
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error((data && data.error) || "Falha na requisicao.");
return data;
}
const kbAuth = {
// email + senha (login | signup)
async password({ mode, email, password, name }) {
return postJson("/api/auth/password", { mode, email, password, name });
},
// codigo por e-mail (OTP)
async otpSend({ email, mode, name }) {
return postJson("/api/auth/otp/send", { email, mode, name });
},
async otpVerify({ email, token }) {
return postJson("/api/auth/otp/verify", { email, token });
},
// recuperacao de senha
async recover(email) {
return postJson("/api/auth/recover", { email });
},
// Google OAuth — retorna { url } para redirecionar a janela de topo
async oauth(provider = "google") {
return postJson("/api/auth/oauth", { provider });
},
// valida a sessao atual; retorna { user } ou lanca
async session() {
const res = await kbApi.authFetch("/api/auth/session", { method: "GET" });
if (!res.ok) throw new Error("unauthenticated");
return res.json();
},
async signout() {
try { await kbApi.authFetch("/api/auth/signout", { method: "POST" }); } catch (e) {}
kbApi.clearSession();
}
};
const kbConnectors = {
// lista catalogo + status de conexao do usuario
async list() {
const res = await kbApi.authFetch("/api/connectors", { method: "GET" });
if (!res.ok) throw new Error("Falha ao carregar conectores.");
return res.json();
},
async connect(connectorId, credentials) {
const res = await kbApi.authFetch("/api/connectors/connect", {
method: "POST",
body: JSON.stringify({ connectorId, credentials: credentials || {} })
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error((data && data.error) || "Falha ao conectar.");
return data;
},
async test(connectorId, credentials) {
const res = await kbApi.authFetch("/api/connectors/test", {
method: "POST",
body: JSON.stringify({ connectorId, credentials })
});
return res.json().catch(() => ({ ok: false }));
},
async disconnect(connectorId) {
const res = await kbApi.authFetch("/api/connectors/disconnect", {
method: "POST",
body: JSON.stringify({ connectorId })
});
return res.ok;
},
async oauthStart(connectorId, returnTo) {
const res = await kbApi.authFetch("/api/connectors/oauth/start", {
method: "POST",
body: JSON.stringify({ connectorId, returnTo })
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error((data && data.error) || "OAuth indisponivel.");
return data; // { url }
},
async health(connectorId) {
const res = await kbApi.authFetch("/api/connectors/health", {
method: "POST",
body: JSON.stringify(connectorId ? { connectorId } : {})
});
return res.json().catch(() => ({ ok: false }));
}
};
// reavalia um workflow apos conexoes (auto-continuar)
const kbChat = {
async continueWorkflow(workflow) {
const res = await kbApi.authFetch("/api/chat/continue", {
method: "POST",
body: JSON.stringify(workflow && workflow.id ? { workflowId: workflow.id, workflow } : { workflow })
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error((data && data.error) || "Falha ao continuar.");
return data;
}
};
const kbApprovals = {
async confirm(approvalId) {
const res = await kbApi.authFetch("/api/chat/approvals/" + encodeURIComponent(approvalId) + "/confirm", {
method: "POST",
body: "{}"
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "Falha ao confirmar acao.");
return data;
}
};
const kbAgents = {
async list() {
const res = await kbApi.authFetch("/api/agents", { method: "GET" });
if (!res.ok) throw new Error("Falha ao carregar agents.");
return res.json();
},
async create(agent) {
const res = await kbApi.authFetch("/api/agents", { method: "POST", body: JSON.stringify(agent) });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "Falha ao criar agent.");
return data;
},
async update(id, patch) {
const res = await kbApi.authFetch("/api/agents/" + encodeURIComponent(id), { method: "PATCH", body: JSON.stringify(patch) });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "Falha ao editar agent.");
return data;
},
async remove(id) {
const res = await kbApi.authFetch("/api/agents/" + encodeURIComponent(id), { method: "DELETE" });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "Falha ao excluir agent.");
return data;
}
};
const kbWorkflows = {
// executa o workflow de verdade (motor real + credenciais dos conectores conectados)
async run(id, body) {
const res = await kbApi.authFetch("/api/workflows/" + encodeURIComponent(id) + "/run", {
method: "POST",
body: JSON.stringify(body || {})
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error((data && data.error) || "Falha ao executar o workflow.");
return data; // { run }
},
// publica o workflow (versao imutavel) — passa a ser elegivel ao agendador (cron)
async publish(id) {
const res = await kbApi.authFetch("/api/workflows/" + encodeURIComponent(id) + "/publish", {
method: "POST",
body: "{}"
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const error = new Error((data && data.error) || "Falha ao publicar o workflow.");
error.data = data;
throw error;
}
return data; // { ok, version, versionId, webhookUrl, issues }
},
async remove(id, force) {
const qs = force ? "?force=true" : "";
const res = await kbApi.authFetch("/api/workflows/" + encodeURIComponent(id) + qs, { method: "DELETE" });
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const error = new Error(data.error || "Falha ao excluir flow.");
error.data = data;
throw error;
}
return data;
}
};
const kbBilling = {
async summary() {
const res = await kbApi.authFetch("/api/billing", { method: "GET" });
if (!res.ok) throw new Error("Falha ao carregar billing.");
return res.json();
},
// Assinatura: inicia checkout do plano e devolve a URL do Stripe (ou modo demo).
async checkout(planId) {
const res = await kbApi.authFetch("/api/billing/checkout", { method: "POST", body: JSON.stringify({ planId }) });
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Falha ao iniciar o checkout.");
return data; // { mode, url }
},
// Portal do cliente (gerir assinatura/cartao/cancelamento).
async portal() {
const res = await kbApi.authFetch("/api/billing/portal", { method: "POST", body: "{}" });
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Falha ao abrir o portal.");
return data;
},
// Creditos de IA: saldo atual + pacotes disponiveis.
async credits() {
const res = await kbApi.authFetch("/api/billing/credits", { method: "GET" });
if (!res.ok) throw new Error("Falha ao carregar creditos.");
return res.json(); // { balance, packs }
},
// Recarga de creditos: inicia o checkout do pacote e devolve a URL do Stripe.
async buyCredits(packId) {
const res = await kbApi.authFetch("/api/billing/credits", { method: "POST", body: JSON.stringify({ packId }) });
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Falha ao comprar creditos.");
return data; // { mode, url }
}
};
const kbMetrics = {
async org(days = 30) {
const res = await kbApi.authFetch("/api/org/metrics?days=" + days, { method: "GET" });
if (!res.ok) throw new Error("Falha ao carregar metricas.");
return res.json();
}
};
const kbRuns = {
async detail(runId) {
const res = await kbApi.authFetch("/api/runs/" + encodeURIComponent(runId), { method: "GET" });
if (!res.ok) throw new Error("Falha ao carregar execucao.");
return res.json();
},
async cancel(runId) {
const res = await kbApi.authFetch("/api/workflows/runs/" + encodeURIComponent(runId) + "/cancel", { method: "POST", body: "{}" });
return res.ok;
}
};
Object.assign(window, { kbApi, kbAuth, kbConnectors, kbChat, kbApprovals, kbAgents, kbWorkflows, kbBilling, kbMetrics, kbRuns });
/* ===== auth.jsx ===== */
// auth.jsx — Kobar Flows login / signup screen
const { useState: useStateAu, useRef: useRefAu, useEffect: useEffectAu } = React;
function GoogleG({ size = 18 }) {
return (
);
}
function Field({ icon, type = "text", value, onChange, placeholder, label, autoFocus, rightSlot, disabled }) {
return (
{label}
onChange(e.target.value)} placeholder={placeholder} />
{rightSlot}
);
}
function AuthScreen({ onAuth }) {
const [mode, setMode] = useStateAu("login"); // login | signup
const [flow, setFlow] = useStateAu("form"); // form | code
const [show, setShow] = useStateAu(false);
const [name, setName] = useStateAu("");
const [email, setEmail] = useStateAu("");
const [pass, setPass] = useStateAu("");
const [code, setCode] = useStateAu(["", "", "", "", "", ""]);
const [remember, setRemember] = useStateAu(true);
const [cooldown, setCooldown] = useStateAu(0);
const [busy, setBusy] = useStateAu(""); // "" | "password" | "google" | "otp" | "verify" | "recover"
const [error, setError] = useStateAu("");
const [info, setInfo] = useStateAu("");
const codeRefs = useRefAu([]);
const clearMsgs = () => { setError(""); setInfo(""); };
useEffectAu(() => {
if (cooldown <= 0) return;
const id = setInterval(() => setCooldown((value) => Math.max(0, value - 1)), 1000);
return () => clearInterval(id);
}, [cooldown]);
// sucesso de auth: persiste sessao e sobe para o App
const handleSession = (data) => {
if (data && data.session) {
kbApi.setSession(data.session, { persist: remember });
onAuth(data.session);
return true;
}
return false;
};
const submit = async (e) => {
e && e.preventDefault();
if (busy || cooldown > 0) return;
clearMsgs();
if (!email || !pass) { setError("Informe e-mail e senha."); return; }
setBusy("password");
try {
const data = await kbAuth.password({ mode, email, password: pass, name });
if (!handleSession(data)) {
// signup sem sessao = precisa confirmar e-mail
setInfo("Conta criada! Confirme seu e-mail para entrar.");
}
} catch (err) {
setError(err.message || "Falha ao autenticar.");
} finally {
setBusy("");
}
};
const google = async () => {
if (busy) return;
clearMsgs();
setBusy("google");
try {
const data = await kbAuth.oauth("google");
const top = window.top || window;
top.location.href = data.url;
} catch (err) {
setError(err.message || "Nao foi possivel iniciar o login com Google.");
setBusy("");
}
};
const sendCode = async (e) => {
e && e.preventDefault();
if (busy) return;
clearMsgs();
if (!email) { setError("Informe seu e-mail para receber o codigo."); return; }
setBusy("otp");
try {
await kbAuth.otpSend({ email, mode, name });
setFlow("code");
setCooldown(45);
setInfo("Enviamos um codigo de 6 digitos para o seu e-mail.");
} catch (err) {
setError(err.message || "Nao foi possivel enviar o codigo.");
} finally {
setBusy("");
}
};
const verifyCode = async (e) => {
e && e.preventDefault();
if (busy) return;
clearMsgs();
const token = code.join("");
if (token.length < 6) { setError("Digite os 6 digitos do codigo."); return; }
setBusy("verify");
try {
const data = await kbAuth.otpVerify({ email, token });
if (!handleSession(data)) setError("Codigo invalido ou expirado.");
} catch (err) {
setError(err.message || "Codigo invalido ou expirado.");
} finally {
setBusy("");
}
};
const recover = async () => {
if (busy) return;
clearMsgs();
if (!email) { setError("Informe seu e-mail para recuperar a senha."); return; }
setBusy("recover");
try {
await kbAuth.recover(email);
setInfo("Se existir uma conta com esse e-mail, enviamos um link de recuperacao.");
} catch (err) {
setError(err.message || "Nao foi possivel enviar o link.");
} finally {
setBusy("");
}
};
const setDigit = (i, v) => {
v = v.replace(/\D/g, "").slice(-1);
setCode((c) => { const n = [...c]; n[i] = v; return n; });
if (v && i < 5) codeRefs.current[i + 1]?.focus();
};
const pasteCode = (e) => {
const raw = (e.clipboardData && e.clipboardData.getData("text")) || "";
const digits = raw.replace(/\D/g, "").slice(0, 6);
if (digits.length < 2) return;
e.preventDefault();
setCode(Array.from({ length: 6 }, (_, i) => digits[i] || ""));
setTimeout(() => codeRefs.current[Math.min(digits.length, 6) - 1]?.focus(), 0);
};
const anyBusy = !!busy;
return (
{/* Left brand panel */}
Automação conversacional para o seu negócio.
Descreva o que precisa em linguagem natural. A Kobar identifica, conecta suas ferramentas e constrói o workflow para você.
Mais de 200 conectores prontos
Modelos de IA locais ou na nuvem
Do chat ao fluxo publicado em minutos
© 2026 Kobar Labs
Privacidade
Termos
{/* Right form panel */}
{mode === "login" ? "Acesse sua conta" : "Crie sua conta"}
{mode === "login" ? "Bem-vindo de volta ao Kobar Flows." : "Comece a automatizar em poucos minutos."}
{(error || info) && (
{error || info}
)}
{flow === "form" ? (
<>
{busy === "google" ? "Conectando…" : "Continuar com o Google"}
ou continue com e-mail
>
) : (
)}
{mode === "login" ? "Ainda não tem uma conta? " : "Já tem uma conta? "}
{ setMode(mode === "login" ? "signup" : "login"); setFlow("form"); clearMsgs(); }}>
{mode === "login" ? "Criar conta" : "Entrar"}
Conexão segura · seus dados ficam no seu ambiente
);
}
Object.assign(window, { AuthScreen });
/* ===== home.jsx ===== */
// home.jsx — Composer + ChatHome (variants A & B) + conversation view
const { useState: useStateH, useRef: useRefH, useEffect: useEffectH, useMemo: useMemoH } = React;
// Heurística de visão: cloud (Claude/Gemini/OpenAI moderno) interpreta imagens;
// no Ollama depende do modelo (llava, *-vision, *vl, gemma3, minicpm-v, etc.).
function modelSupportsVision(provider, model) {
const m = String(model || "").toLowerCase();
if (provider === "anthropic" || provider === "gemini") return true;
if (provider === "openai") return !/gpt-3\.5|davinci|babbage/.test(m);
if (provider === "deepseek") return false;
// Ollama e demais: casa por padrões conhecidos de modelos multimodais.
return /llava|vision|[-_]?vl\b|qwen2.*vl|gemma3|minicpm-v|moondream|bakllava|llama3\.2-vision|pixtral|cogvlm|internvl|granite.*vision/.test(m);
}
function ComposerConnectors({ up, connectedIds }) {
const ids = Array.from(connectedIds || []).map((id) => frontConnectorId ? frontConnectorId(id) : id);
const [open, setOpen] = useStateH(false);
const [on, setOn] = useStateH({});
const ref = useRefH(null);
useEffectH(() => {
setOn((current) => {
const next = {};
ids.forEach((id) => { next[id] = current[id] !== undefined ? current[id] : true; });
return next;
});
}, [ids.join("|")]);
useEffectH(() => {
if (!open) return;
const h = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener("mousedown", h);
return () => document.removeEventListener("mousedown", h);
}, [open]);
const count = ids.filter((id) => on[id]).length;
return (
setOpen((o) => !o)}>
{count > 0 && {count} }
{open && (
Conectores do sistema{count}/{ids.length}
{ids.length === 0 && (
Nenhum conector conectado ainda.
)}
{ids.map((id) => (
{(CONNECTORS[id] && CONNECTORS[id].label) || id}
{on[id] ? "ligado" : "desligado"}
setOn((s) => ({ ...s, [id]: !s[id] }))} />
))}
)}
);
}
function Composer({ provider, onProvider, onSend, placeholder, dark, autoFocus, voice, up,
webSearchOn, onWebSearch, thinkingOn, onThinking, modelChoice, connectedIds }) {
const [val, setVal] = useStateH("");
const [recording, setRecording] = useStateH(false);
const [voiceMsg, setVoiceMsg] = useStateH("");
const [atts, setAtts] = useStateH([]);
const [attMenu, setAttMenu] = useStateH(false);
const ta = useRefH(null);
const recog = useRefH(null);
const baseVal = useRefH("");
const fileRef = useRefH(null);
const attRef = useRefH(null);
const grow = () => {
const el = ta.current; if (!el) return;
el.style.height = "0px";
el.style.height = Math.min(el.scrollHeight, 200) + "px";
};
useEffectH(() => { const id = requestAnimationFrame(grow); return () => cancelAnimationFrame(id); }, [val]);
useEffectH(() => () => { try { recog.current && recog.current.stop(); } catch (e) {} }, []);
useEffectH(() => {
if (!attMenu) return;
const h = (e) => { if (attRef.current && !attRef.current.contains(e.target)) setAttMenu(false); };
document.addEventListener("mousedown", h);
return () => document.removeEventListener("mousedown", h);
}, [attMenu]);
const submit = () => {
const t = val.trim();
if (!t && !atts.length) return;
const sending = atts;
setVal(""); setAtts([]);
onSend(t, sending);
};
// ---- Anexos: arquivos, imagens e áudios ----
const fmtSize = (n) => n < 1024 ? n + " B" : n < 1048576 ? (n / 1024).toFixed(0) + " KB" : (n / 1048576).toFixed(1) + " MB";
const pickFiles = (accept) => {
if (fileRef.current) { fileRef.current.accept = accept || ""; fileRef.current.click(); }
setAttMenu(false);
};
const onFiles = (e) => {
const files = Array.from(e.target.files || []);
// Limite de leitura inline: arquivos maiores nao sao enviados como conteudo.
const MAX_READ_BYTES = 8 * 1024 * 1024;
files.forEach((f) => {
const kind = f.type.startsWith("image/") ? "image" : f.type.startsWith("audio/") ? "audio" : "file";
const entry = { id: Math.random().toString(36).slice(2), name: f.name, size: f.size, type: f.type || "", kind, url: null };
setAtts((a) => [...a, entry]);
// Le o conteudo de TODOS os anexos (imagem, audio e arquivos de dados como
// .xlsx/.csv) como data URL, para o servidor usar o conteudo real do anexo
// como fonte — em vez de puxar dados de um conector conectado.
if (f.size <= MAX_READ_BYTES) {
const reader = new FileReader();
reader.onload = () => setAtts((a) => a.map((x) => x.id === entry.id ? { ...x, url: reader.result } : x));
reader.readAsDataURL(f);
}
});
e.target.value = "";
};
const removeAtt = (id) => setAtts((a) => a.filter((x) => x.id !== id));
const speechSupported = typeof window !== "undefined" && (window.SpeechRecognition || window.webkitSpeechRecognition);
const startVoice = () => {
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SR) { setVoiceMsg("Voz indisponível neste navegador."); return; }
const r = new SR();
r.lang = "pt-BR"; r.continuous = true; r.interimResults = true;
baseVal.current = val ? val.replace(/\s+$/, "") + " " : "";
setVoiceMsg("Solicitando microfone...");
r.onstart = () => { setRecording(true); setVoiceMsg("Ouvindo em pt-BR..."); };
r.onaudiostart = () => setVoiceMsg("Pode falar.");
r.onresult = (e) => {
let txt = "";
for (let i = e.resultIndex; i < e.results.length; i++) txt += e.results[i][0].transcript;
setVal((baseVal.current + txt).replace(/^\s+/, ""));
setVoiceMsg("Transcrevendo...");
};
r.onnomatch = () => setVoiceMsg("Não entendi. Tente falar mais perto do microfone.");
r.onend = () => { setRecording(false); recog.current = null; setVoiceMsg((m) => m === "Transcrevendo..." ? "" : m); };
r.onerror = (event) => {
const err = event && event.error;
const msg = err === "not-allowed" ? "Permissão do microfone negada."
: err === "no-speech" ? "Não detectei fala."
: err === "audio-capture" ? "Microfone não encontrado."
: "Falha ao ouvir. Tente novamente.";
setVoiceMsg(msg);
setRecording(false);
recog.current = null;
};
recog.current = r;
try { r.start(); } catch (e) { setRecording(false); setVoiceMsg("Microfone já está em uso."); }
};
const stopVoice = () => { try { recog.current && recog.current.stop(); } catch (e) {} setRecording(false); setVoiceMsg(""); };
const toggleVoice = () => {
if (!speechSupported) { setVoiceMsg("Reconhecimento de voz não é suportado neste navegador."); return; }
if (recording) stopVoice(); else startVoice();
};
const activeModel = (modelChoice && modelChoice[provider]) || (PROVIDERS[provider] && PROVIDERS[provider].model) || provider;
// Aviso de visão: alerta quando há imagem anexada mas o modelo não interpreta imagens.
const hasImageAtt = atts.some((a) => a.kind === "image");
const modelHasVision = modelSupportsVision(provider, activeModel);
const providerTools = (PROVIDERS[provider] && PROVIDERS[provider].tools) || [];
const catalogEntry = (typeof window !== "undefined" && window.MODEL_CATALOG
? window.MODEL_CATALOG.find((c) => c.provider === provider)
: null);
const allowedModels = catalogEntry && Array.isArray(catalogEntry.webSearchModels) ? catalogEntry.webSearchModels : null;
const thinkingModels = catalogEntry && Array.isArray(catalogEntry.thinkingModels) ? catalogEntry.thinkingModels : [];
const hasSearchTool = providerTools.includes("web_search") || providerTools.includes("google_search");
const modelAllowed = !allowedModels || allowedModels.length === 0 || allowedModels.includes(activeModel);
const webSearchAvailable = Boolean(onWebSearch) && hasSearchTool && modelAllowed;
const thinkingAvailable = Boolean(onThinking) && thinkingModels.includes(activeModel);
useEffectH(() => {
if (!thinkingAvailable && thinkingOn && onThinking) onThinking(false);
}, [thinkingAvailable, thinkingOn, activeModel, provider]);
return (
{atts.length > 0 && (
{atts.map((a) => (
{a.kind === "image" && a.url
?
: }
{a.name}
{a.kind === "image" ? "Imagem" : a.kind === "audio" ? "Áudio" : "Arquivo"} · {fmtSize(a.size)}
removeAtt(a.id)} aria-label="Remover anexo">
))}
)}
{hasImageAtt && !modelHasVision && (
O modelo {activeModel} não interpreta imagens. Escolha um modelo com visão (ex.: gemma3, llava, llama3.2-vision) para analisar o anexo.
)}
);
}
function categorySuggestions(label) {
if (!label) return SUGGESTION_POOL;
const cat = CONNECTOR_CATEGORIES.find((c) => c.label === label);
if (!cat) return SUGGESTION_POOL;
return SUGGESTION_POOL.filter((s) => s.connectors.some((id) => cat.ids.includes(id)));
}
function buildSuggestions(label) {
return pickRandom(categorySuggestions(label), 4);
}
// only categories that actually have suggestion ideas
const FILTER_CATS = CONNECTOR_CATEGORIES.filter((c) => SUGGESTION_POOL.some((s) => s.connectors.some((id) => c.ids.includes(id))));
function dynamicGreeting(name) {
const hour = new Date().getHours();
const greeting = hour < 12 ? "Bom dia" : hour < 18 ? "Boa tarde" : "Boa noite";
const complements = [
"O que vamos automatizar hoje?",
"Quer analisar dados, criar um relatório ou montar um workflow?",
"Como posso ajudar sua operação agora?"
];
const index = Math.floor(Date.now() / 86400000) % complements.length;
return `${greeting}${name ? `, ${name}` : ""}. ${complements[index]}`;
}
function CategoryTabs({ selected, onSelect }) {
return (
onSelect(null)}>Todos
{FILTER_CATS.map((cat) => (
onSelect(cat.label)}>{cat.label}
))}
);
}
// ---------- EMPTY STATE — Variant A (minimal, centered) ----------
function EmptyA({ provider, onProvider, onSend, userName, webSearchOn, onWebSearch, thinkingOn, onThinking, modelChoice, connectedIds }) {
const [sel, setSel] = useStateH(null);
const suggestions = useMemoH(() => buildSuggestions(sel), [sel]);
return (
{dynamicGreeting(userName)}
Descreva em linguagem natural e eu transformo em um workflow pronto para rodar.
{STARTERS.map((s, i) => onSend(s.text)} />)}
Sugestões por conector
{suggestions.map((s, i) => onSend(s.desc)} />)}
);
}
// ---------- EMPTY STATE — Variant B (connector-forward discovery) ----------
function EmptyB({ provider, onProvider, onSend, webSearchOn, onWebSearch, thinkingOn, onThinking, modelChoice, connectedIds }) {
const [sel, setSel] = useStateH(null);
const suggestions = useMemoH(() => buildSuggestions(sel), [sel]);
return (
KOBAR FLOWS
Comece pelo conector. Eu construo o resto.
Escolha uma categoria para ver ideias de automação ou descreva a sua no campo abaixo.
{suggestions.map((s, i) => onSend(s.desc)} />)}
{STARTERS.slice(0, 3).map((s, i) => onSend(s.text)} />)}
);
}
// ---------- CONVERSATION VIEW ----------
function Conversation({ messages, provider, onProvider, onSend, chatTitle, webSearchOn, onWebSearch, thinkingOn, onThinking, modelChoice, connectedIds, onConnect, onOpenFlow }) {
const endRef = useRefH(null);
useEffectH(() => { endRef.current?.scrollTo({ top: endRef.current.scrollHeight, behavior: "smooth" }); }, [messages]);
return (
Kobar pode cometer erros. Revise os workflows antes de publicar.
);
}
function ChatHome({ variant, provider, onProvider, onSend, messages, chatTitle, userName, webSearchOn, onWebSearch, thinkingOn, onThinking, modelChoice, connectedIds, onConnect, onOpenFlow }) {
if (messages && messages.length) {
return ;
}
return variant === "B"
?
: ;
}
Object.assign(window, { Composer, ChatHome });
/* ===== workflow.jsx ===== */
// workflow.jsx — interactive node canvas (drag nodes, zoom/pan/drop, settings), runs tab
const { useState: useStateW, useRef: useRefW, useEffect: useEffectW } = React;
const NODE_W = 248, NODE_H = 96, GAP_X = 312, ORIGIN_X = 64, ORIGIN_Y = 200;
const nodePos = (i) => ({ x: ORIGIN_X + i * GAP_X, y: ORIGIN_Y + (i % 2 === 0 ? 0 : 70) });
const TRIGGER_CONN = { schedule: 1, webhook: 1 };
let DRAGGED_CONNECTOR = null;
function WorkflowCanvas({ nodes, revealed, building, runStep, positions, setPositions,
onDropConnector, onOpenSettings, nodeCreds, fitNonce, fullscreen, onToggleFullscreen }) {
const kindColor = (k) => k === "TRIGGER" ? "var(--copper)" : "var(--olive)";
const wrapRef = useRefW(null);
const [base, setBase] = useStateW({ scale: 1, x: 0, y: 0 });
const [zoom, setZoom] = useStateW(1);
const [pan, setPan] = useStateW({ x: 0, y: 0 });
const [dragOver, setDragOver] = useStateW(false);
const [draggingNode, setDraggingNode] = useStateW(null);
const panState = useRefW(null);
const nodeDrag = useRefW(null);
const userZoomed = useRefW(false);
const scaleRef = useRefW(1);
const visible = Math.min(revealed, nodes.length);
const posOf = (n, i) => positions[n.id] || nodePos(i);
const fit = React.useCallback(() => {
const el = wrapRef.current; if (!el || visible === 0) { setBase({ scale: 1, x: 0, y: 0 }); setZoom(1); setPan({ x: 0, y: 0 }); return; }
const W = el.clientWidth, H = el.clientHeight, margin = 90;
const pts = nodes.slice(0, visible).map((n, i) => posOf(n, i));
const minX = Math.min(...pts.map((p) => p.x)), maxX = Math.max(...pts.map((p) => p.x)) + NODE_W;
const minY = Math.min(...pts.map((p) => p.y)), maxY = Math.max(...pts.map((p) => p.y)) + NODE_H;
const bbW = maxX - minX, bbH = maxY - minY;
const scale = Math.min(1, (W - 2 * margin) / bbW, (H - 2 * margin) / bbH);
setBase({ scale, x: (W - bbW * scale) / 2 - minX * scale, y: (H - bbH * scale) / 2 - minY * scale });
setZoom(1); setPan({ x: 0, y: 0 }); userZoomed.current = false;
}, [visible, nodes, positions]);
const fitRef = useRefW(fit); fitRef.current = fit;
// fit on fullscreen toggle, on node-count change (unless the user manually zoomed), and on explicit refit
React.useLayoutEffect(() => { fitRef.current(); }, [fullscreen]);
useEffectW(() => { if (!userZoomed.current) fitRef.current(); }, [visible]);
useEffectW(() => { if (fitNonce) { const t = setTimeout(() => fitRef.current(), 110); return () => clearTimeout(t); } }, [fitNonce]);
useEffectW(() => {
const ro = new ResizeObserver(() => { if (!userZoomed.current) fitRef.current(); });
if (wrapRef.current) ro.observe(wrapRef.current);
return () => ro.disconnect();
}, []);
scaleRef.current = base.scale * zoom;
const zoomAt = (factor) => { userZoomed.current = true; setZoom((z) => Math.max(0.4, Math.min(2.6, z * factor))); };
const onWheel = (e) => { if (!e.ctrlKey && !e.metaKey) return; e.preventDefault(); zoomAt(e.deltaY < 0 ? 1.1 : 0.9); };
// background pan
const onPointerDown = (e) => {
if (e.target.closest(".kb-node")) return;
userZoomed.current = true;
panState.current = { sx: e.clientX, sy: e.clientY, px: pan.x, py: pan.y };
wrapRef.current.setPointerCapture(e.pointerId);
wrapRef.current.classList.add("grabbing");
};
const onPointerMove = (e) => {
if (!panState.current) return;
setPan({ x: panState.current.px + (e.clientX - panState.current.sx), y: panState.current.py + (e.clientY - panState.current.sy) });
};
const endPan = () => { panState.current = null; wrapRef.current?.classList.remove("grabbing"); };
// node drag
const startNodeDrag = (e, n, i) => {
e.stopPropagation();
const start = posOf(n, i);
nodeDrag.current = { id: n.id, sx: e.clientX, sy: e.clientY, ox: start.x, oy: start.y };
setDraggingNode(n.id);
const move = (ev) => {
const d = nodeDrag.current; if (!d) return;
const s = scaleRef.current || 1;
setPositions((prev) => ({ ...prev, [d.id]: { x: d.ox + (ev.clientX - d.sx) / s, y: d.oy + (ev.clientY - d.sy) / s } }));
};
const up = () => { nodeDrag.current = null; setDraggingNode(null); window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); };
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
};
const tx = base.x + pan.x, ty = base.y + pan.y, scale = base.scale * zoom;
return (
{ e.preventDefault(); setDragOver(true); }}
onDragLeave={(e) => { if (e.target === wrapRef.current) setDragOver(false); }}
onDrop={(e) => { e.preventDefault(); setDragOver(false); const id = (e.dataTransfer && e.dataTransfer.getData("text/connector")) || DRAGGED_CONNECTOR; DRAGGED_CONNECTOR = null; if (id) onDropConnector(id); }}>
{nodes.map((n, i) => {
if (i === 0 || i >= revealed) return null;
const a = posOf(nodes[i - 1], i - 1), b = posOf(n, i);
const x1 = a.x + NODE_W, y1 = a.y + NODE_H / 2, x2 = b.x, y2 = b.y + NODE_H / 2, mx = (x1 + x2) / 2;
const d = `M${x1},${y1} C${mx},${y1} ${mx},${y2} ${x2},${y2}`;
const lit = runStep > i;
return (
= 0 && !lit ? " idle" : "")} />
{lit && }
);
})}
{nodes.map((n, i) => {
if (i >= revealed) return null;
const p = posOf(n, i);
const state = runStep < 0 ? "" : (runStep === i ? " running" : (runStep > i ? " done" : ""));
// Configurado = creds locais OU conector ja conectado na plataforma
// (credentialRef real, nao "pending:") — evita re-pedir credenciais.
const configured = (nodeCreds && nodeCreds[n.id]) || (n.credentialRef && !String(n.credentialRef).startsWith("pending:"));
return (
{ if (e.target.closest(".kb-node-gear,.kb-node-port")) return; startNodeDrag(e, n, i); }}>
{n.kind === "TRIGGER" ? "GATILHO" : "AÇÃO"}
{ e.stopPropagation(); onOpenSettings(n); }} data-tip2="Configurar credenciais">
{n.title}
{n.sub}
{configured &&
}
{state === " running" &&
}
{state === " done" &&
}
);
})}
zoomAt(0.83)}>
{Math.round(scale * 100)}%
zoomAt(1.2)}>
fit()}>
{dragOver &&
Solte para adicionar ao fluxo
}
{visible === 0 && !dragOver && (
Workflow em branco
Arraste um conector do painel à esquerda para começar, ou peça ao agente no chat à direita.
)}
);
}
function Switch({ on, onClick }) {
return ;
}
// ---- Node credential settings modal ----
function NodeSettingsModal({ node, saved, onSave, onClose }) {
const fields = CONN_CREDENTIALS[node.connector] || DEFAULT_CREDENTIALS;
const [vals, setVals] = useStateW(saved || {});
const set = (k, v) => setVals((s) => ({ ...s, [k]: v }));
const c = CONNECTORS[node.connector];
return (
e.stopPropagation()}>
{node.title}
{c.label} · {node.kind === "TRIGGER" ? "Gatilho" : "Ação"}
{fields.map((f) => (
{f.label}{f.secret && }
{f.type === "select" ? (
set(f.k, e.target.value)}>
{f.options.map((o) => {o} )}
) : (
set(f.k, e.target.value)} />
)}
))}
As credenciais ficam no seu ambiente e não são compartilhadas.
Cancelar
onSave(node.id, vals)}> Salvar credenciais
);
}
function ConnectorsStatusPopover({ active, states, onToggle, onClose }) {
const ref = useRefW(null);
useEffectW(() => {
const h = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose(); };
document.addEventListener("mousedown", h);
return () => document.removeEventListener("mousedown", h);
}, []);
return (
Conectores ativos{active.filter((id) => states[id]).length}/{active.length}
{active.length === 0 &&
Nenhum conector no fluxo ainda.
}
{active.map((id) => (
{CONNECTORS[id].label}
{states[id] ? "ligado" : "desligado"}
onToggle(id)} />
))}
);
}
function WorkflowAIPanel({ provider, onProvider, messages, onSend }) {
const scroll = useRefW(null);
useEffectW(() => { scroll.current?.scrollTo({ top: scroll.current.scrollHeight, behavior: "smooth" }); }, [messages]);
return (
);
}
function WorkflowConnectorsPanel({ open, onToggle, activeConn, connStates }) {
const list = ["schedule", "webhook", "http", "gworkspace", "m365", "slack", "github", "notion", "discord", "sheets", "gmail", "stripe", "telegram"];
return (
);
}
function WorkflowContextPanel({ nodeCount, connectors }) {
const [open, setOpen] = useStateW(true);
const icons = ["schedule", "http", "sheets", "slack", "github", "notion", "stripe", "gmail"];
return (
setOpen((o) => !o)}>
Contexto do workflow Grafo atual e gates de publicação
{open && (
Conectores ativos{connectors}
Nós{nodeCount}
Skill principalGeração de workflow
{icons.map((id) => )}
A publicação usa o grafo atual e o estado das conexões.
)}
);
}
function RunsTab({ runs, nodes }) {
const [sel, setSel] = useStateW(runs[0] ? runs[0].id : null);
if (!runs.length) {
return (
Nenhuma execução ainda
Clique em Executar para rodar o workflow. O histórico aparece aqui.
);
}
const run = runs.find((r) => r.id === sel) || runs[0];
return (
{runs.map((r) => (
setSel(r.id)}>
{r.when}
{r.dur}
))}
Execução {run.id} {run.trigger} · {run.when} · {run.dur}
{nodes.map((n, i) => (
{n.title} {n.sub}
{(0.2 + i * 0.3).toFixed(1)}s
))}
);
}
function WorkflowPage({ blueprint, provider, onProvider, onBack }) {
const blank = !!blueprint.blank;
const [nodes, setNodes] = useStateW(blueprint.nodes);
const [positions, setPositions] = useStateW({});
const [revealed, setRevealed] = useStateW(blank ? 0 : 0);
const [building, setBuilding] = useStateW(!blank);
const [msgs, setMsgs] = useStateW([]);
const [connOpen, setConnOpen] = useStateW(true);
const [tab, setTab] = useStateW("editor");
const [fullscreen, setFullscreen] = useStateW(false);
const [runStep, setRunStep] = useStateW(-1);
const [running, setRunning] = useStateW(false);
const [runs, setRuns] = useStateW([]);
const [toast, setToast] = useStateW(null);
const [connStates, setConnStates] = useStateW({});
const [nodeCreds, setNodeCreds] = useStateW({});
const [settingsNode, setSettingsNode] = useStateW(null);
const [fitNonce, setFitNonce] = useStateW(0);
const [publishing, setPublishing] = useStateW(false);
const [published, setPublished] = useStateW(false);
const idc = useRefW(100);
const rc = useRefW(0);
const nid = () => "wm" + (++idc.current);
const activeConn = Array.from(new Set(nodes.slice(0, Math.max(revealed, 0)).map((n) => n.connector)));
useEffectW(() => {
setConnStates((s) => { const n = { ...s }; activeConn.forEach((id) => { if (!(id in n)) n[id] = true; }); return n; });
}, [revealed, nodes.length]);
const showToast = (text, kind) => { setToast({ text, kind }); setTimeout(() => setToast(null), 2600); };
useEffectW(() => {
if (blank) {
setMsgs([{ id: nid(), role: "ai", via: PROVIDERS[provider].model,
text: "Workflow em branco criado. Arraste conectores do painel à esquerda para montar o fluxo, ou me diga o que você quer automatizar que eu monto para você." }]);
return;
}
let i = 0;
const tick = () => {
i += 1; setRevealed(i);
if (i < blueprint.nodes.length) setTimeout(tick, 620);
else {
setBuilding(false);
setMsgs([{ id: nid(), role: "ai", via: PROVIDERS[provider].model,
text: `Montei o workflow “${blueprint.name}” com ${blueprint.nodes.length} etapas:\n1 · Gatilho de agendamento, todo dia às 9h\n2 · Buscar a cotação via requisição HTTP\n3 · Registrar a linha no Google Sheets\n\nClique no ícone de engrenagem de cada nó para configurar as credenciais.` }]);
}
};
const t = setTimeout(tick, 350);
return () => clearTimeout(t);
}, []);
const addNode = (node, asTrigger) => {
setNodes((ns) => {
const insertTrigger = asTrigger && ns.some((x) => x.kind === "TRIGGER");
const next = insertTrigger ? ns : [...ns, node];
const idx = next.length - 1;
setPositions((p) => ({ ...p, [node.id]: nodePos(idx) }));
setTimeout(() => { setRevealed(next.length); setFitNonce((n) => n + 1); }, 40);
return next;
});
};
const send = (text) => {
setMsgs((m) => [...m, { id: nid(), role: "user", text }]);
const thinkId = nid();
setMsgs((m) => [...m, { id: thinkId, role: "ai", thinking: true, thinkingText: "Ajustando o workflow…" }]);
setTimeout(() => {
const newNode = { id: nid(), kind: "ACTION", title: "Notificar no Slack", sub: "slack.post_message", connector: "slack" };
addNode(newNode);
setMsgs((m) => m.map((x) => x.id === thinkId ? { id: thinkId, role: "ai", via: PROVIDERS[provider].model,
text: "Adicionei uma etapa de aviso no Slack ao final do fluxo. O nó já aparece conectado no canvas." } : x));
}, 1700);
};
const dropConnector = (id) => {
const c = CONNECTORS[id];
const isTrigger = !!TRIGGER_CONN[id];
const title = id === "schedule" ? "Agendamento" : (id === "webhook" ? "Webhook recebido" : c.label);
const sub = id === "schedule" ? "schedule.cron" : (isTrigger ? id + ".trigger" : id + ".action");
const node = { id: nid(), kind: isTrigger ? "TRIGGER" : "ACTION", title, sub, connector: id };
if (tab !== "editor") setTab("editor");
if (isTrigger && nodes.some((x) => x.kind === "TRIGGER")) { showToast("O fluxo já tem um gatilho", "warn"); return; }
addNode(node, isTrigger);
showToast(`${isTrigger ? "Gatilho" : "Ação"} “${title}” adicionado`, "ok");
};
const runTimer = useRefW(null);
const runWorkflow = async () => {
if (running || building) return;
if (revealed === 0) { showToast("Adicione ao menos um passo para executar", "warn"); return; }
setTab("editor"); setRunning(true); setRunStep(0);
const total = Math.min(nodes.length, revealed) || nodes.length;
const firstConn = nodes[0] ? nodes[0].connector : "schedule";
// animacao visual dos passos enquanto roda
let step = 0;
clearInterval(runTimer.current);
const startAnim = (intervalMs, onDone) => {
runTimer.current = setInterval(() => {
step += 1;
if (step < total) setRunStep(step);
else { clearInterval(runTimer.current); setRunStep(total); if (onDone) onDone(); }
}, intervalMs);
};
// EXECUCAO REAL: quando o flow esta salvo (tem id), chama o motor de verdade.
// O backend resolve as credenciais dos conectores ja conectados (o cliente NAO
// precisa reinserir) e envia para os destinos reais (ex.: Discord).
if (blueprint.id) {
startAnim(600);
try {
const { run } = await kbWorkflows.run(blueprint.id);
clearInterval(runTimer.current); setRunStep(total);
const ok = run.status === "success";
const statusLabel = ok ? "sucesso" : (run.status === "cancelled" ? "cancelado" : "erro");
setTimeout(() => {
setRunning(false); setRunStep(-1);
const id = run.id || ("#" + (1042 + (++rc.current)));
setRuns((r) => [{
id, status: statusLabel, when: "agora",
dur: ((run.durationMs || 0) / 1000).toFixed(1) + " s",
trigger: firstConn, steps: run.steps || []
}, ...r]);
showToast(ok ? "Execução real concluída — enviado aos conectores" : ("Falha: " + ((run.error && run.error.message) || "erro na execução")), ok ? "ok" : "warn");
}, 350);
} catch (e) {
clearInterval(runTimer.current);
setRunning(false); setRunStep(-1);
showToast(e.message || "Falha ao executar o workflow", "warn");
}
return;
}
// FALLBACK (flow ainda nao salvo, sem id): apenas simulacao visual.
startAnim(760, () => {
setTimeout(() => {
setRunning(false); setRunStep(-1);
const id = "#" + (1042 + (++rc.current));
setRuns((r) => [{ id, status: "sucesso", when: "agora", dur: (0.6 + total * 0.3).toFixed(1) + " s", trigger: firstConn }, ...r]);
showToast("Simulação concluída (salve/publique o flow para executar de verdade)", "ok");
}, 700);
});
};
useEffectW(() => () => clearInterval(runTimer.current), []);
const toggleConn = (id) => setConnStates((s) => ({ ...s, [id]: !s[id] }));
const saveCreds = (nodeId, vals) => { setNodeCreds((c) => ({ ...c, [nodeId]: vals })); setSettingsNode(null); showToast("Credenciais salvas", "ok"); };
// Publica de verdade: cria versao imutavel e habilita o agendador (cron) a rodar
// o flow automaticamente. Sem id (flow nao salvo), nao ha o que publicar.
const publishFlow = async () => {
if (publishing) return;
if (!blueprint.id) { showToast("Salve o flow (gere pelo chat) antes de publicar", "warn"); return; }
setPublishing(true);
try {
const r = await kbWorkflows.publish(blueprint.id);
setPublished(true);
showToast(`Publicado (v${r.version}). O agendador roda no horário configurado.`, "ok");
} catch (e) {
const hasErr = e.data && Array.isArray(e.data.issues) && e.data.issues.some((i) => i.severity === "error");
showToast(hasErr ? "Corrija os erros de validação antes de publicar." : (e.message || "Falha ao publicar"), "warn");
} finally { setPublishing(false); }
};
return (
{blueprint.name}
{blueprint.tag}
tag
setTab("editor")}>Editor
setTab("runs")}>Execuções{runs.length ? ` · ${runs.length}` : ""}
{building ? "gerando…" : running ? "executando…" : published ? "publicado · agendador ativo" : "rascunho · salvo localmente"}
PT
{running ? : } Executar
{publishing ? : } {published ? "Publicado" : "Publicar"}
{tab === "editor" ? (
<>
setConnOpen((o) => !o)} activeConn={activeConn} connStates={connStates} />
setFullscreen((f) => !f)} />
connStates[id]).length} />
{!connOpen &&
setConnOpen(true)}> Conectores }
{PROVIDERS[provider].model}
>
) : (
)}
{settingsNode &&
setSettingsNode(null)} />}
{toast && {toast.text}
}
);
}
Object.assign(window, { WorkflowPage });
/* ===== pages.jsx ===== */
// pages.jsx — section screens (light app shell): Flows, Connect, Runs, Models,
// Agents, History, Settings, Upgrade
const { useState: useStateP, useEffect: useEffectP } = React;
const STATUS = {
ativo: { label: "ativo", cls: "ok" },
rascunho: { label: "rascunho", cls: "draft" },
pausado: { label: "pausado", cls: "paused" },
sucesso: { label: "sucesso", cls: "ok" },
erro: { label: "erro", cls: "err" },
"em execução": { label: "em execução", cls: "run" },
};
function StatusPill({ s }) {
const d = STATUS[s] || { label: s, cls: "draft" };
return {d.cls === "run" && }{d.label} ;
}
function SectionShell({ title, subtitle, actions, children, eyebrow }) {
return (
{eyebrow &&
{eyebrow} }
{title}
{subtitle &&
{subtitle}
}
{actions && {actions}
}
);
}
// ---------------- Flows ----------------
function FlowsPage({ onOpen, onNew, onDelete, flows = [] }) {
const [q, setQ] = useStateP("");
const [busyDelete, setBusyDelete] = useStateP("");
const list = flows.filter((f) => f.name.toLowerCase().includes(q.toLowerCase()));
const remove = async (e, flow) => {
e.stopPropagation();
if (busyDelete) return;
const ok = window.confirm(`Excluir o flow "${flow.name}"? Workflows publicados serão arquivados.`);
if (!ok) return;
setBusyDelete(flow.id);
try {
await onDelete(flow);
} finally {
setBusyDelete("");
}
};
return (
Novo workflow}>
{flows.length === 0 && (
Nenhum workflow ainda. Crie o primeiro a partir de um chat ou clique em “Novo workflow”.
)}
{list.map((f) => (
onOpen(f)}
onKeyDown={(e) => { if (e.key === "Enter") onOpen(f); }}>
{f.connectors.map((id, i) => )}
remove(e, f)} disabled={busyDelete === f.id}>
{f.name}
{f.nodes} nós
{f.runs} exec.
{f.lastRun}
#{f.tag}
))}
);
}
// ---------------- Connect ----------------
// Modal de conexao real, dirigido pela config do conector (/api/connectors).
// `connectorId` aqui e o id do BACKEND (ex.: slack_webhook).
function ConnectorModal({ connectorId, catalog, onClose, onConnected, returnTo, forceReconnect }) {
const cfg = (catalog || []).find((c) => c.id === connectorId) || null;
const name = (cfg && cfg.name) || connectorId;
const method = (cfg && cfg.effectiveMethod) || "credentials";
const fields = (cfg && cfg.fields) || [];
const [vals, setVals] = useStateP({});
const [busy, setBusy] = useStateP(false);
const [err, setErr] = useStateP("");
const [done, setDone] = useStateP(!!(cfg && cfg.connected) && !forceReconnect);
const [displayName, setDisplayName] = useStateP((cfg && cfg.displayName) || "");
const setVal = (k, v) => setVals((s) => ({ ...s, [k]: v }));
const submit = async () => {
if (busy) return;
setErr("");
const missing = fields.filter((f) => !((vals[f.k] || "").trim()));
if (missing.length) { setErr("Preencha: " + missing.map((f) => f.label).join(", ")); return; }
setBusy(true);
try {
const data = await kbConnectors.connect(connectorId, vals);
setDone(true);
setDisplayName((data.connection && data.connection.displayName) || "");
onConnected && onConnected(connectorId, true);
} catch (e) {
setErr(e.message || "Falha ao conectar.");
} finally {
setBusy(false);
}
};
const authorize = async () => {
if (busy) return;
setErr(""); setBusy(true);
try {
const { url } = await kbConnectors.oauthStart(connectorId, returnTo || (window.location.pathname + window.location.search));
const width = 520, height = 640;
const left = window.screenX + Math.max(0, (window.outerWidth - width) / 2);
const top = window.screenY + Math.max(0, (window.outerHeight - height) / 2);
const popup = window.open(url, "kobar_oauth", `width=${width},height=${height},left=${left},top=${top}`);
if (!popup) (window.top || window).location.href = url;
} catch (e) {
setErr(e.message || "OAuth indisponivel."); setBusy(false);
}
};
const disconnect = async () => {
if (busy) return;
setBusy(true);
try { await kbConnectors.disconnect(connectorId); onConnected && onConnected(connectorId, false); onClose(); }
catch (e) { setErr("Falha ao desconectar."); setBusy(false); }
};
const errBox = err ? (
{err}
) : null;
return (
e.stopPropagation()}>
{name}
{done ? <> conectado> : forceReconnect ? "reautorizar" : "não conectado"}
{done ? (
<>
{displayName ? `Conectado como ${displayName}.` : "Conector conectado e pronto para uso nos seus workflows."}
Fechar
Desconectar
>
) : method === "oauth2" ? (
(cfg && cfg.oauthAvailable) ? (
<>
{forceReconnect ? `Reautorize o Kobar Flows para renovar os tokens e permissoes de ${name}.` : `Autorize o Kobar Flows a acessar sua conta ${name}. Você poderá revogar o acesso quando quiser.`}
{errBox}
{busy ? "Abrindo…" : forceReconnect ? `Reautorizar ${name}` : `Autorizar com ${name}`}
>
) : (
A conexão OAuth de {name} ainda não está configurada no servidor.
Configure {(cfg && cfg.missingEnv && cfg.missingEnv.length) ? cfg.missingEnv.join(", ") : "as credenciais OAuth do provedor"} para habilitar.
)
) : (
<>
Informe as credenciais para conectar {name}. Elas são validadas e guardadas criptografadas no seu ambiente.
{fields.map((f) => (
{f.label}
setVal(f.k, e.target.value)} autoFocus={f === fields[0]} />
{f.help && {f.help} }
))}
{errBox}
{busy ? "Conectando…" : `Conectar ${name}`}
>
)}
);
}
function ConnectPage({ connectors = [], initialConnector = null, onConnected }) {
const [q, setQ] = useStateP("");
const [modal, setModal] = useStateP(null);
const backendId = (id) => backendConnectorId ? backendConnectorId(id) : id;
const uiId = (id) => frontConnectorId ? frontConnectorId(id) : id;
const byUi = Object.fromEntries(connectors.map((item) => [uiId(item.id || item.connectorId), item]));
const connectedIds = connectors.filter((item) => item.connected).map((item) => uiId(item.id || item.connectorId));
const isConn = (id) => connectedIds.includes(id);
const needsReconnect = (id) => !!(byUi[id] && byUi[id].needsReconnect);
const supported = new Set(connectors.map((item) => uiId(item.id || item.connectorId)));
const isSupported = (id) => supported.has(id);
useEffectP(() => {
if (initialConnector) {
const id = uiId(initialConnector);
setModal({ id, connected: isConn(id) });
}
}, [initialConnector]);
return (
{connectedIds.length} conectados}>
{CONNECTOR_CATEGORIES.map((cat) => {
const ids = cat.ids.filter((id) => CONNECTORS[id] && CONNECTORS[id].label.toLowerCase().includes(q.toLowerCase()));
if (!ids.length) return null;
return (
{cat.label}
{ids.map((id) => (
{CONNECTORS[id].label}
{isConn(id) ? <> conectado> : needsReconnect(id) ? "reconectar" : "não conectado"}
{isSupported(id) ? (
setModal({ id, forceReconnect: needsReconnect(id) })}>{isConn(id) ? "Gerenciar" : needsReconnect(id) ? "Reconectar" : "Conectar"}
) : (
Em breve
)}
))}
);
})}
{modal && setModal(null)} />}
);
}
// ---------------- Runs ----------------
const STEP_STATUS = { success: "ok", failed: "err", error: "err", running: "run", pending: "draft", skipped: "draft" };
function RunDetailModal({ runId, onClose }) {
const [data, setData] = useStateP(null);
const [err, setErr] = useStateP("");
useEffectP(() => {
let active = true;
kbRuns.detail(runId).then((d) => active && setData(d)).catch((e) => active && setErr(e.message || "Falha"));
return () => { active = false; };
}, [runId]);
const run = data && data.run;
const steps = (data && data.steps) || [];
return (
e.stopPropagation()}>
Execução
{runId.slice(0, 8)} · {run ? run.status : (err || "carregando…")}
{!data && !err &&
}
{err &&
{err}
}
{run && (
Status
Gatilho {run.trigger_type || "—"}
Duração {run.duration_ms != null ? (run.duration_ms / 1000).toFixed(2) + " s" : "—"}
{run.error &&
Erro {run.error.message || JSON.stringify(run.error)}
}
Passos ({steps.length})
{steps.map((s, i) => (
{s.operation}
{s.duration_ms != null ? s.duration_ms + "ms" : ""}
{s.error &&
{s.error.message || JSON.stringify(s.error)}
}
{s.output &&
{JSON.stringify(s.output, null, 1).slice(0, 600)} }
))}
{steps.length === 0 &&
Sem passos registrados.
}
)}
);
}
function RunsPage({ runs = [] }) {
const [f, setF] = useStateP("todos");
const [openRun, setOpenRun] = useStateP(null);
const list = runs.filter((r) => f === "todos" || (STATUS[r.status] && STATUS[r.status].cls === f));
const isUuid = (id) => /^[0-9a-f]{8}-[0-9a-f]{4}-/i.test(String(id || ""));
return (
{[["todos", "Todos"], ["ok", "Sucesso"], ["err", "Erro"], ["run", "Em execução"]].map(([k, l]) => (
setF(k)}>{l}
))}
{runs.length === 0 && (
Nenhuma execução ainda. Publique e rode um workflow para ver o histórico aqui.
)}
Workflow Gatilho Status Duração Quando
{list.map((r) => (
isUuid(r.id) && setOpenRun(r.id)}>
{r.flow}
{r.dur}
{r.when}
))}
{openRun && setOpenRun(null)} />}
);
}
// ---------------- Agents ----------------
const ALL_MODELS = MODEL_CATALOG.flatMap((m) => m.models);
function AgentSkillsPicker({ selected, onToggle }) {
return (
{SKILLS_CATALOG.map((s) => {
const on = selected.includes(s.id);
return (
onToggle(s.id)}>
{s.label} {s.desc}
{on ? : }
);
})}
);
}
function AgentEditorModal({ agent, onClose, onSubmit }) {
const editing = !!agent;
const [name, setName] = useStateP(agent ? agent.name : "");
const [desc, setDesc] = useStateP(agent ? agent.desc : "");
const [model, setModel] = useStateP(agent ? agent.model : ALL_MODELS[0]);
const [skills, setSkills] = useStateP(agent ? [...(agent.skillIds || agent.skills || [])] : ["build_flow"]);
const [instr, setInstr] = useStateP(agent ? (agent.instructions || "") : "");
const toggle = (id) => setSkills((s) => s.includes(id) ? s.filter((x) => x !== id) : [...s, id]);
const valid = name.trim() && skills.length;
return (
e.stopPropagation()}>
{editing ? "Editar agente" : "Novo agente"} Defina o comportamento e as habilidades
Habilidades (skills) {skills.length} selecionadas
Instruções
Cancelar
valid && onSubmit({ name: name.trim(), desc: desc.trim() || "Agente personalizado.", model, skillIds: skills, instructions: instr.trim() })}>
{editing ? "Salvar alterações" : "Criar agente"}
);
}
function AgentDetail({ agent, onBack, onToggleStatus, onEdit, onDelete }) {
// Skills podem vir como ids (catalogo) ou como labels (dados reais do banco).
const rawSkills = agent.skillIds || agent.skills || [];
const skills = rawSkills.map((label, i) => {
const found = SKILLS_CATALOG.find((s) => s.id === label || s.label === label);
return found || { id: "sk" + i, label, desc: "", icon: "skills" };
});
return (
Voltar
onToggleStatus(agent.id)}>
{agent.status === "ativo" ? "Pausar" : "Ativar"}
onDelete(agent)}> Excluir
onEdit(agent)}> Editar
>}>
Visão geral
Status
Modelo {agent.model}
Habilidades {skills.length}
Instruções
{agent.instructions || "Sem instruções definidas."}
);
}
function AgentsPage({ initialAgents = [] }) {
const [agents, setAgents] = useStateP(initialAgents);
const [creating, setCreating] = useStateP(false);
const [editId, setEditId] = useStateP(null);
const [openId, setOpenId] = useStateP(null);
const [busy, setBusy] = useStateP("");
useEffectP(() => setAgents(initialAgents), [JSON.stringify(initialAgents)]);
useEffectP(() => {
let active = true;
kbAgents.list().then((data) => { if (active) setAgents(data.agents || []); }).catch(() => {});
return () => { active = false; };
}, []);
const toPayload = (data) => ({
name: data.name,
desc: data.desc,
model: data.model,
status: data.status || "ativo",
skills: data.skillIds || data.skills || [],
instructions: data.instructions || ""
});
const create = async (data) => {
setBusy("create");
try {
const res = await kbAgents.create(toPayload(data));
setAgents((a) => [res.agent, ...a]);
setCreating(false);
} catch (err) {
window.alert((err && err.message) || "Falha ao criar agent.");
} finally {
setBusy("");
}
};
const saveEdit = async (data) => {
if (!editId) return;
setBusy(editId);
try {
const res = await kbAgents.update(editId, toPayload(data));
setAgents((a) => a.map((x) => x.id === editId ? res.agent : x));
setEditId(null);
} catch (err) {
window.alert((err && err.message) || "Falha ao salvar agent.");
} finally {
setBusy("");
}
};
const toggleStatus = async (id) => {
const agent = agents.find((a) => a.id === id);
if (!agent) return;
const status = agent.status === "ativo" ? "pausado" : "ativo";
setBusy(id);
try {
const res = await kbAgents.update(id, { status });
setAgents((a) => a.map((x) => x.id === id ? res.agent : x));
} catch (err) {
window.alert((err && err.message) || "Falha ao alterar status.");
} finally {
setBusy("");
}
};
const deleteAgent = async (agent) => {
if (!window.confirm(`Excluir o agent "${agent.name}"?`)) return;
setBusy(agent.id);
try {
await kbAgents.remove(agent.id);
setAgents((a) => a.filter((x) => x.id !== agent.id));
setOpenId(null);
} catch (err) {
window.alert((err && err.message) || "Falha ao excluir agent.");
} finally {
setBusy("");
}
};
const editingAgent = editId ? agents.find((a) => a.id === editId) : null;
if (openId) {
const agent = agents.find((a) => a.id === openId);
if (agent) return (
<>
setOpenId(null)} onToggleStatus={toggleStatus} onEdit={() => setEditId(agent.id)} onDelete={deleteAgent} />
{editingAgent && setEditId(null)} onSubmit={saveEdit} />}
>
);
}
return (
setCreating(true)}> Novo agente}>
{agents.map((a) => (
setOpenId(a.id)}>
{a.name}
{a.desc}
{a.model}
{((a.skillIds || a.skills) || []).length} skills
))}
{creating && setCreating(false)} onSubmit={create} />}
);
}
// ---------------- History ----------------
function HistoryPage() {
return (
{HISTORY.map((h) => (
{h.text}
{h.when}
))}
);
}
// ---------------- Settings ----------------
const USAGE_LABELS = {
ai_calls: "Chamadas de IA", chat_messages: "Mensagens de chat",
web_searches: "Buscas online", workflow_runs: "Execuções"
};
function UsageBar({ label, used, limit }) {
const unlimited = limit == null || limit < 0;
const pct = unlimited ? 0 : Math.min(100, Math.round((used / Math.max(1, limit)) * 100));
const color = pct >= 100 ? "#B0462F" : pct >= 80 ? "#C9842B" : "var(--accent, #B8734A)";
return (
{label}
{used}{unlimited ? "" : " / " + limit}
);
}
function SettingsPage({ provider, onUpgrade, user, isAdmin, onModels }) {
const planName = (user && user.plan) || "Free";
const current = PLANS.find((p) => p.name.toLowerCase() === planName.toLowerCase()) || PLANS[0];
const [m, setM] = useStateP(null);
const [loading, setLoading] = useStateP(true);
useEffectP(() => {
let active = true;
kbMetrics.org(30).then((data) => { if (active) { setM(data); setLoading(false); } }).catch(() => active && setLoading(false));
return () => { active = false; };
}, []);
const usageMetrics = ["ai_calls", "chat_messages", "web_searches", "workflow_runs"];
const alerts = (m && m.alerts) || [];
return (
{alerts.length > 0 && (
{alerts.map((a, i) => (
{a.message}
))}
)}
Perfil
Nome {(user && user.name) || "—"}
E-mail {(user && user.email) || "—"}
Organização {(user && user.org) || "—"}
Permissão {isAdmin ? "Administrador" : "Usuário"}
Plano atual
{current.name}
{current.price}{current.per}
{m && m.subscriptionStatus && m.subscriptionStatus !== "none" ? "Assinatura: " + m.subscriptionStatus : "Sem assinatura ativa"}
Ver planos
Uso este mês
{loading ?
Carregando…
: !m ?
Indisponível.
: (
<>
{usageMetrics.map((k) =>
)}
{m.runs && (
Execuções: {m.runs.total} no total · {m.runs.error} com erro · taxa de erro {Math.round((m.runs.errorRate || 0) * 100)}%
)}
>
)}
Preferências
Idioma Português (BR)
Provedor padrão {PROVIDERS[provider].label} · {providerModel(provider, null)}
Tema Claro
{isAdmin &&
Gerenciar modelos dos usuários}
);
}
// ---------------- Upgrade / Plans ----------------
function UpgradePage({ onBack }) {
const [credits, setCredits] = useStateP(null);
const [packs, setPacks] = useStateP([]);
const [busy, setBusy] = useStateP("");
const [err, setErr] = useStateP("");
useEffectP(() => {
let alive = true;
kbBilling.credits().then((d) => {
if (!alive) return;
setCredits(d.balance || null);
setPacks(d.packs || []);
}).catch(() => {});
return () => { alive = false; };
}, []);
const go = (urlPromise, key) => {
setErr(""); setBusy(key);
urlPromise
.then((data) => { if (data && data.url) (window.top || window).location.href = data.url; else setBusy(""); })
.catch((e) => { setErr(e.message || "Falha ao processar."); setBusy(""); });
};
const onPlan = (planId) => go(kbBilling.checkout(planId), "plan-" + planId);
const onBuy = (packId) => go(kbBilling.buyCredits(packId), "pack-" + packId);
const totalCredits = credits && credits.total != null ? credits.total : null;
const fmt = (n) => (n < 0 ? "ilimitado" : Number(n).toLocaleString("pt-BR"));
return (
Voltar}>
{err && {err}
}
{PLANS.map((p) => (
{p.popular &&
Mais popular }
{p.name}
{p.tagline}
{p.price}{p.per}
{p.features.map((f, i) => {f} )}
p.id !== "free" && onPlan(p.id)}>
{p.current ? "Plano atual" : p.id === "free" ? "Grátis" : (busy === "plan-" + p.id ? "Redirecionando…" : "Fazer upgrade")}
))}
{/* Créditos de IA na nuvem */}
Créditos de IA na nuvem
Modelos locais (Ollama) são ilimitados e não consomem créditos. Modelos na nuvem (OpenAI, Claude, Gemini, DeepSeek) consomem créditos pelo uso real.
{totalCredits == null ? "—" : fmt(totalCredits)}
créditos disponíveis
{credits && credits.monthly != null && credits.monthly >= 0 && (
{fmt(credits.monthly)} do plano · {fmt(credits.wallet || 0)} comprados
)}
{packs.map((pk) => (
{Number(pk.credits).toLocaleString("pt-BR")} créditos
{pk.bonus > 0 && +{pk.bonus}% bônus }
R$ {pk.amountBRL}
onBuy(pk.id)}>
{busy === "pack-" + pk.id ? "Redirecionando…" : "Comprar"}
))}
);
}
Object.assign(window, {
StatusPill, SectionShell, FlowsPage, ConnectPage, RunsPage,
ModelsPage, AgentsPage, HistoryPage, SettingsPage, UpgradePage,
});
/* ===== models-admin.jsx ===== */
// models-admin.jsx — catalogo dinamico da API + preview por usuario (admin)
function normalizeModelEntries(models) {
return (models || []).map((m) => {
if (typeof m === "string") return { id: m, name: m };
if (!m || !m.id) return null;
return {
id: m.id,
name: m.name || m.id,
supportsThinking: !!m.supportsThinking,
supportsWebSearch: !!m.supportsWebSearch,
supportsVision: !!m.supportsVision
};
}).filter(Boolean);
}
function mapProviderFromApi(p) {
const providerId = p.id || p.provider;
const entries = normalizeModelEntries(p.models);
const ids = entries.map((m) => m.id);
const connected = p.connected != null ? p.connected : (p.status === "connected" || p.hasApiKey === true);
return {
provider: providerId,
label: p.label,
kind: p.kind,
short: p.short,
color: p.color,
connected,
status: p.status || (connected ? "connected" : "disconnected"),
hasApiKey: !!p.hasApiKey,
apiKeyPreview: p.apiKeyPreview || null,
defaultModel: p.defaultModel || ids[0] || "",
enabledModels: Array.isArray(p.enabledModels) ? p.enabledModels : [],
isDefault: !!p.isDefault,
models: ids,
modelEntries: entries,
webSearchModels: entries.filter((m) => m.supportsWebSearch).map((m) => m.id),
thinkingModels: entries.filter((m) => m.supportsThinking).map((m) => m.id)
};
}
function modelDisplayLabel(entries, modelId) {
const hit = (entries || []).find((m) => m.id === modelId);
if (!hit) return modelId;
return hit.name && hit.name !== hit.id ? `${hit.name} (${hit.id})` : hit.id;
}
function chatVisibleModels(row) {
if (!row || (!row.connected && row.status !== "connected")) return [];
const entries = row.modelEntries || (row.models || []).map((id) => ({ id, name: id }));
const enabledIds = row.enabledModels && row.enabledModels.length ? row.enabledModels : row.models || [];
return entries.filter((m) => enabledIds.includes(m.id));
}
function ModelSelect({ models, modelEntries, value, onChange, disabled }) {
const [open, setOpen] = useStateP(false);
const ref = React.useRef(null);
const options = (models || []).map((mod) => (typeof mod === "string" ? mod : mod.id)).filter(Boolean);
useEffectP(() => {
if (!open) return;
const h = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener("mousedown", h);
return () => document.removeEventListener("mousedown", h);
}, [open]);
return (
!disabled && setOpen((o) => !o)}>
{modelDisplayLabel(modelEntries, value) || value || "Selecione"}
{open && (
{options.map((mod) => (
{ onChange(mod); setOpen(false); }}>
{modelDisplayLabel(modelEntries, mod)}
{mod === value && }
))}
)}
);
}
function ModelsPage({ provider, onProvider, modelChoice, onModelChoice, catalog, isAdmin, onCatalogChange, onRefresh, orgMembers, currentUserId }) {
const [list, setList] = useStateP([]);
const [expanded, setExpanded] = useStateP(null);
const [keys, setKeys] = useStateP({});
const [busy, setBusy] = useStateP("");
const [notice, setNotice] = useStateP("");
const [selectedUserId, setSelectedUserId] = useStateP("");
const [loading, setLoading] = useStateP(false);
useEffectP(() => { if (catalog && catalog.length) setList(catalog); }, [catalog]);
useEffectP(() => {
if (!catalog || !catalog.length) {
setLoading(true);
(onRefresh ? onRefresh() : Promise.resolve([])).finally(() => setLoading(false));
}
}, []);
const setProviderRow = (providerId, patch) => {
setList((rows) => {
const next = rows.map((row) => row.provider === providerId ? { ...row, ...patch } : row);
onCatalogChange && onCatalogChange(next);
return next;
});
};
const callModels = async (providerId, body, message) => {
setBusy(providerId + ":" + body.action);
setNotice("");
try {
const res = await kbApi.authFetch("/api/models", { method: "POST", body: JSON.stringify({ provider: providerId, ...body }) });
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "Falha ao salvar configuracao.");
setNotice(message || "Configuracao salva.");
return data;
} catch (e) {
setNotice(e.message || "Falha ao salvar configuracao.");
return null;
} finally {
setBusy("");
}
};
const saveKey = async (row) => {
const value = (keys[row.provider] || "").trim();
if (!value) { setNotice("Informe a chave/API URL antes de salvar."); return; }
const data = await callModels(row.provider, { action: "save_key", apiKey: value, defaultModel: row.defaultModel || row.models[0] }, "Provedor conectado.");
if (data) {
setKeys((s) => ({ ...s, [row.provider]: "" }));
setProviderRow(row.provider, {
connected: true,
status: "connected",
hasApiKey: true,
defaultModel: row.defaultModel || row.models[0],
apiKeyPreview: data.apiKeyPreview || row.apiKeyPreview || null
});
if (onRefresh) onRefresh();
}
};
const removeKey = async (row) => {
const data = await callModels(row.provider, { action: "remove_key" }, "Provedor desconectado.");
if (data) setProviderRow(row.provider, { connected: false, status: "disconnected", hasApiKey: false, apiKeyPreview: null });
};
const testKey = async (row) => {
setBusy(row.provider + ":test");
setNotice("");
try {
const payload = row.provider === "ollama"
? { provider: row.provider, baseUrl: (keys[row.provider] || "").trim() }
: { provider: row.provider, apiKey: (keys[row.provider] || "").trim() };
const res = await kbApi.authFetch("/api/models/test", { method: "POST", body: JSON.stringify(payload) });
const data = await res.json().catch(() => ({}));
if (data.ok && Array.isArray(data.models) && data.models.length) {
const entries = normalizeModelEntries(data.models);
setProviderRow(row.provider, {
models: data.models,
modelEntries: entries,
defaultModel: row.defaultModel && data.models.includes(row.defaultModel) ? row.defaultModel : data.models[0]
});
}
setNotice(data.ok ? `Conexao OK. ${data.models ? data.models.length : 0} modelos encontrados.` : (data.error || "Teste falhou."));
} catch (e) {
setNotice(e.message || "Teste falhou.");
} finally {
setBusy("");
}
};
const setDefaultModel = async (row, modelId) => {
const data = await callModels(row.provider, { action: "update_config", defaultModel: modelId }, "Modelo padrao atualizado.");
if (data) {
setProviderRow(row.provider, { defaultModel: modelId });
onModelChoice && onModelChoice(row.provider, modelId);
}
};
const toggleModel = async (row, modelId) => {
const current = row.enabledModels || [];
const allEnabled = current.length === 0;
const base = allEnabled ? (row.models || []) : current;
let next = base.includes(modelId) ? base.filter((id) => id !== modelId) : [...base, modelId];
if (!next.length) next = [modelId];
const data = await callModels(row.provider, { action: "update_config", enabledModels: next }, "Modelos disponiveis atualizados.");
if (data) setProviderRow(row.provider, { enabledModels: next });
};
const previewMember = (orgMembers || []).find((member) => member.id === selectedUserId) || null;
const previewModels = list
.filter((row) => row.connected || row.status === "connected")
.flatMap((row) => chatVisibleModels(row).map((entry) => ({
id: entry.id,
label: modelDisplayLabel(row.modelEntries, entry.id),
provider: row.provider
})));
return (
{notice} : {loading ? "Carregando..." : `${list.length} provedores`} }>
{isAdmin && (
Visualizar como usuario
setSelectedUserId(e.target.value)} style={{ width: "100%", border: 0, background: "transparent", padding: "10px 12px" }}>
Configuracao da organizacao (todos)
{(orgMembers || []).map((member) => (
{member.name}{member.id === currentUserId ? " (voce)" : ""} · {member.email}
))}
{previewMember && (
{previewModels.length} modelos visiveis para {previewMember.name}
)}
)}
{list.map((m) => {
const p = PROVIDERS[m.provider] || { label: m.label || m.provider, kind: m.kind || "cloud", placeholder: "API key" };
const models = m.models || [];
const entries = m.modelEntries || models.map((id) => ({ id, name: id }));
const selected = (modelChoice && modelChoice[m.provider]) || m.defaultModel || models[0];
const isDefaultProvider = provider === m.provider;
const open = expanded === m.provider;
const enabled = m.enabledModels && m.enabledModels.length ? m.enabledModels : models;
const visibleToUser = chatVisibleModels(m);
return (
{p.label || m.label || m.provider}
{models.length} modelos · {(p.kind || m.kind) === "local" ? "no dispositivo" : "Cloud API"}
{m.connected ? <> ativo> : "configurar"}
Modelo padrao do provedor
isAdmin ? setDefaultModel(m, mod) : onModelChoice(m.provider, mod)} />
{isAdmin && previewMember && m.connected && (
Visivel para {previewMember.name}
{visibleToUser.length
? visibleToUser.map((entry) => modelDisplayLabel(entries, entry.id)).join(" · ")
: "Nenhum modelo liberado neste provedor."}
)}
{isAdmin && open && (
{m.apiKeyPreview && (
Chave salva {m.apiKeyPreview}
)}
{m.provider === "ollama" ? "URL/API key do Ollama" : "Chave de API"}
setKeys((s) => ({ ...s, [m.provider]: e.target.value }))} />
testKey(m)} disabled={busy === m.provider + ":test"}>{busy === m.provider + ":test" ? "Testando..." : "Testar"}
saveKey(m)} disabled={busy === m.provider + ":save_key"}>{busy === m.provider + ":save_key" ? "Salvando..." : "Salvar conexao"}
{m.connected && removeKey(m)} disabled={busy === m.provider + ":remove_key"}>Desconectar }
Modelos liberados para usuarios
{models.map((modelId) => {
const on = enabled.includes(modelId);
return (
toggleModel(m, modelId)}>
{modelDisplayLabel(entries, modelId)}
{on ? : }
);
})}
)}
{isDefaultProvider ? (
Provedor padrao
) : m.connected ? (
onProvider(m.provider)}>Definir como padrao do chat
) : }
{isAdmin ? (
setExpanded(open ? null : m.provider)}>
{open ? "Fechar" : (m.connected ? "Gerenciar" : "Conectar")}
) : (
{m.connected ? "Disponivel" : "Indisponivel"}
)}
);
})}
);
}
Object.assign(window, { mapProviderFromApi, ModelsPage, ModelSelect });
/* ===== app.jsx ===== */
// app.jsx — top-level state + routing across all sections
const { useState: useStateA, useRef: useRefA, useEffect: useEffectA } = React;
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"homeLayout": "A",
"accent": "#B8734A",
"loaderSpeed": 1.3,
"showTexture": true
}/*EDITMODE-END*/;
// ---- mapeamento de dados reais (bootstrap) para o formato das telas ----
const STATUS_FLOW = { published: "ativo", paused: "pausado", draft: "rascunho", archived: "rascunho" };
// conectores do backend -> ids de icone do front
const CONNECTOR_ALIAS = {
http_request: "http", google_sheets_append: "sheets", slack_webhook: "slack",
gmail: "gmail", notion: "notion", github: "github", stripe: "stripe",
discord: "discord", telegram: "telegram", webhook: "webhook", schedule: "schedule",
google_drive: "gdrive", google_calendar: "gcal",
microsoft_outlook: "outlook", microsoft_teams: "teams", microsoft_excel: "m365",
hubspot: "hubspot", airtable: "airtable", trello: "trello"
};
const RUN_STATUS = { success: "sucesso", error: "erro", running: "em execução", queued: "em execução", cancelled: "erro" };
function aliasConnector(id) { return CONNECTOR_ALIAS[id] || id; }
function initialView() {
try {
const params = new URLSearchParams(window.location.search);
const value = params.get("view");
return ["chats", "connect", "flows", "runs", "models", "agents", "history", "settings", "upgrade"].includes(value) ? value : "chats";
} catch (e) {
return "chats";
}
}
function relTime(iso) {
if (!iso) return "—";
const diff = Date.now() - new Date(iso).getTime();
if (isNaN(diff)) return "—";
const min = Math.floor(diff / 60000);
if (min < 1) return "agora";
if (min < 60) return `há ${min} min`;
const h = Math.floor(min / 60);
if (h < 24) return `há ${h} h`;
const d = Math.floor(h / 24);
return `há ${d} d`;
}
function fmtDuration(ms) {
if (ms == null) return "—";
return (ms / 1000).toFixed(1).replace(".", ",") + " s";
}
function mapFlows(workflows, runs) {
return (workflows || []).map((wf) => {
const nodes = wf.nodes || [];
const connectors = Array.from(new Set(nodes.map((n) => aliasConnector(n.connectorId)).filter(Boolean)));
const wfRuns = (runs || []).filter((r) => (r.workflow_id || r.workflowId) === wf.id);
const last = wfRuns[0];
return {
id: wf.id,
name: wf.name,
tag: wf.tag || "rascunho",
status: STATUS_FLOW[wf.status] || "rascunho",
nodes: nodes.length,
connectors,
lastRun: last ? relTime(last.started_at || last.startedAt) : "Nunca",
runs: wfRuns.length
};
});
}
function mapRuns(runs, workflows) {
const nameById = {};
(workflows || []).forEach((w) => { nameById[w.id] = w.name; });
return (runs || []).map((r) => ({
id: r.id,
flow: nameById[r.workflow_id || r.workflowId] || "Workflow",
status: RUN_STATUS[r.status] || r.status,
trigger: aliasConnector(r.trigger_type || r.trigger),
dur: fmtDuration(r.duration_ms),
when: relTime(r.started_at)
}));
}
function mapBlueprint(wf) {
return {
id: wf.id,
name: wf.name,
tag: wf.tag || "rascunho",
nodes: (wf.nodes || []).map((n) => ({
id: n.id, kind: n.kind, title: n.title, sub: n.operationId, connector: aliasConnector(n.connectorId),
// preserva os dados reais do no para execucao/credenciais (nao re-pedir ao usuario)
connectorId: n.connectorId, operationId: n.operationId,
config: n.config || {}, credentialRef: n.credentialRef || null
})),
edges: wf.edges || []
};
}
function messageArtifactsFromMetadata(metadata) {
const m = metadata && typeof metadata === "object" ? metadata : {};
const approvals = Array.isArray(m.requiredApprovals) ? m.requiredApprovals : [];
return {
charts: Array.isArray(m.charts) ? m.charts : [],
tables: Array.isArray(m.tables) ? m.tables : [],
reportSections: Array.isArray(m.reportSections) ? m.reportSections : [],
dashboards: Array.isArray(m.dashboards) ? m.dashboards : [],
dashboardWidgets: Array.isArray(m.dashboardWidgets) ? m.dashboardWidgets : [],
actions: approvals.map((approval) => ({
type: "confirm_tool_approval",
label: "Confirmar ação",
connectorId: approval.connectorId,
approvalId: approval.id
}))
};
}
function firstName(name) { return (name || "").trim().split(/\s+/)[0] || ""; }
function App() {
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
const [authReady, setAuthReady] = useStateA(false);
const [authed, setAuthed] = useStateA(false);
const [isAdmin, setIsAdmin] = useStateA(false);
const [user, setUser] = useStateA(null);
const [view, setView] = useStateA(initialView);
const [provider, setProvider] = useStateA("ollama");
const [chats, setChats] = useStateA([]);
const [flows, setFlows] = useStateA([]);
const [runs, setRuns] = useStateA([]);
const [agents, setAgents] = useStateA([]);
const [activeChat, setActiveChat] = useStateA(null);
const [chatTitle, setChatTitle] = useStateA("Novo chat");
const [messages, setMessages] = useStateA([]);
const [collapsed, setCollapsed] = useStateA(false);
const [railExpanded, setRailExpanded] = useStateA(true);
const [modelChoice, setModelChoice] = useStateA({});
const [webSearchOn, setWebSearchOn] = useStateA(false);
const [thinkingOn, setThinkingOn] = useStateA(false);
const [openFlow, setOpenFlow] = useStateA(null);
const [connectorCatalog, setConnectorCatalog] = useStateA([]);
const [modelsCatalog, setModelsCatalog] = useStateA([]);
const [connectedIds, setConnectedIds] = useStateA(() => new Set());
const [connectModal, setConnectModal] = useStateA(null); // { connectorId }
const [pendingConn, setPendingConn] = useStateA(null); // { required:[ids], workflow }
const lastPromptRef = useRefA("");
const pendingReconnectPromptRef = useRefA("");
const sendRef = useRefA(null);
const idc = useRefA(0);
const nid = () => "m" + (++idc.current);
// Carrega os modelos realmente disponiveis na API por provedor.
const refreshModels = async () => {
try {
const res = await kbApi.authFetch("/api/models", { method: "GET" });
if (!res.ok) return [];
const data = await res.json();
const toIds = (arr) => (arr || []).map((m) => (typeof m === "string" ? m : (m && m.id))).filter(Boolean);
const cat = (data.providers || []).map((p) => ({
provider: p.id, label: p.label, kind: p.kind, short: p.short, color: p.color,
// Conectado = status do servidor (que ja exige chave real). Nao confiar em
// p.connected do meta estatico do provedor, que vinha sempre true.
connected: p.status === "connected",
status: p.status || "disconnected",
hasApiKey: !!p.hasApiKey,
defaultModel: p.defaultModel,
enabledModels: p.enabledModels || [],
models: toIds(p.models),
webSearchModels: toIds(p.webSearchModels),
thinkingModels: toIds(p.thinkingModels)
}));
cat.forEach((p) => {
if (PROVIDERS[p.provider]) {
PROVIDERS[p.provider].model = p.defaultModel || PROVIDERS[p.provider].model;
PROVIDERS[p.provider].kind = p.kind || PROVIDERS[p.provider].kind;
}
});
setModelsCatalog(cat);
window.MODEL_CATALOG = cat.length ? cat : window.MODEL_CATALOG;
// Se o provedor atual nao esta conectado, troca para o primeiro conectado
// (com modelos), para o chat nunca abrir num provedor sem chave.
const connected = cat.filter((c) => c.connected && c.models.length > 0);
if (connected.length) {
setProvider((cur) => {
const curOk = connected.some((c) => c.provider === cur);
const next = curOk ? cur : connected[0].provider;
const nextEntry = connected.find((c) => c.provider === next);
if (nextEntry && nextEntry.defaultModel) {
setModelChoice((mc) => (mc[next] ? mc : { ...mc, [next]: nextEntry.defaultModel }));
}
return next;
});
}
return cat;
} catch (e) { return []; }
};
// Carrega catalogo de conectores (config + status) e atualiza o conjunto conectado.
const refreshConnectors = async () => {
try {
const data = await kbConnectors.list();
const items = data.connectors || [];
setConnectorCatalog(items);
setConnectedIds(new Set(items.filter((c) => c.connected).map((c) => c.id)));
return items;
} catch (e) { return []; }
};
useEffectA(() => {
document.documentElement.style.setProperty("--accent", t.accent);
document.documentElement.style.setProperty("--loader-speed", String(t.loaderSpeed));
document.documentElement.style.setProperty("--texture-on", t.showTexture ? "1" : "0");
}, [t.accent, t.loaderSpeed, t.showTexture]);
useEffectA(() => {
const handler = (event) => {
if (event.origin !== window.location.origin) return;
if (!event.data || event.data.type !== "kobar:connector") return;
refreshConnectors();
if (event.data.status === "ok" && event.data.connector) {
onConnectorChanged(event.data.connector, true);
const pendingPrompt = pendingReconnectPromptRef.current;
pendingReconnectPromptRef.current = "";
if (pendingPrompt && sendRef.current) {
setTimeout(() => sendRef.current(pendingPrompt), 450);
}
}
};
window.addEventListener("message", handler);
return () => window.removeEventListener("message", handler);
}, []);
// Carrega o estado real da conta (usuario + dados) a partir do backend.
const loadBootstrap = async () => {
const res = await kbApi.authFetch("/api/bootstrap", { method: "GET" });
if (!res.ok) throw new Error("unauthenticated");
const boot = await res.json();
const session = kbApi.getSession();
const sessUser = (session && session.user) || {};
setUser({
id: boot.user.id,
name: boot.user.name || sessUser.name || "",
email: boot.user.email || sessUser.email || "",
org: boot.user.org || "Workspace",
plan: boot.plan || "Free"
});
setChats(boot.conversations || []);
setFlows(mapFlows(boot.workflows, boot.runs));
setRuns(mapRuns(boot.runs, boot.workflows));
setAgents(boot.agents || []);
setIsAdmin(!!boot.isAdmin);
// status inicial de conexoes a partir do bootstrap (sera refinado por refreshConnectors)
const connConns = boot.connectorConnections || [];
setConnectedIds(new Set(connConns.filter((c) => c.status === "connected").map((c) => c.connectorId)));
setAuthed(true);
refreshConnectors();
refreshModels();
return boot;
};
// Verifica a sessao guardada ao iniciar.
useEffectA(() => {
let active = true;
(async () => {
const session = kbApi.getSession();
if (!session || !session.accessToken) { if (active) { setAuthed(false); setAuthReady(true); } return; }
try {
await loadBootstrap();
} catch (e) {
kbApi.clearSession();
if (active) setAuthed(false);
} finally {
if (active) setAuthReady(true);
}
})();
return () => { active = false; };
}, []);
const onAuth = async () => {
setAuthReady(false);
try { await loadBootstrap(); }
catch (e) { kbApi.clearSession(); setAuthed(false); }
finally { setAuthReady(true); }
};
const logout = async () => {
try { await kbAuth.signout(); } catch (e) {}
kbApi.clearSession();
setAuthed(false);
setIsAdmin(false);
setUser(null);
setChats([]); setFlows([]); setRuns([]); setAgents([]);
setConnectorCatalog([]); setConnectedIds(new Set()); setConnectModal(null); setPendingConn(null);
setMessages([]); setActiveChat(null); setChatTitle("Novo chat"); setView("chats");
setWebSearchOn(false); setThinkingOn(false);
};
const openEditor = (bp) => { setOpenFlow(bp); setView("editor"); };
// Abre um flow pelo id, buscando a definicao COMPLETA (nos/edges/config/credenciais)
// no backend. Usado pelo menu de Workflows e pelo CTA do chat (inclusive ao reabrir
// do historico), garantindo que o canvas sempre receba o grafo montado.
const openFlowById = async (id) => {
if (!id) return;
// Busca a definicao ANTES de abrir o editor: o WorkflowPage inicializa os nos
// apenas na montagem, entao precisamos do grafo pronto antes de montar.
try {
const res = await kbApi.authFetch("/api/workflows/" + encodeURIComponent(id));
const data = await res.json().catch(() => ({}));
if (res.ok && data.workflow) openEditor(mapBlueprint(data.workflow));
else window.alert((data && data.error) || "Não foi possível carregar este workflow.");
} catch (e) { window.alert("Falha ao carregar o workflow."); }
};
const deleteFlow = async (flow) => {
try {
await kbWorkflows.remove(flow.id, false);
setFlows((items) => items.filter((item) => item.id !== flow.id));
} catch (err) {
if (err && err.data && err.data.requiresConfirmation) {
const force = window.confirm("Este flow tem execuções ativas. Arquivar mesmo assim?");
if (!force) return;
await kbWorkflows.remove(flow.id, true);
setFlows((items) => items.filter((item) => item.id !== flow.id));
return;
}
window.alert((err && err.message) || "Falha ao excluir flow.");
}
};
// Abre o modal de conexao para um conector (id do backend).
const openConnect = (connectorId, opts) => {
if (opts && opts.forceReconnect && lastPromptRef.current) {
pendingReconnectPromptRef.current = lastPromptRef.current;
}
setConnectModal({ connectorId, forceReconnect: !!(opts && opts.forceReconnect) });
};
// Auto-continuar: quando todos os conectores pendentes ficam conectados,
// reavalia o workflow no backend e injeta a confirmacao "tudo pronto".
const runContinue = async (workflow) => {
try {
const r = await kbChat.continueWorkflow(workflow);
const finalWf = r.workflow || workflow;
setMessages((m) => [...m, {
id: nid(), role: "ai", text: r.reply || "Tudo conectado! Seu workflow está pronto.",
cta: {
title: "Abrir no editor de workflows",
sub: `${(finalWf.nodes || []).length} nós · pronto para revisar`,
onClick: () => openEditor(mapBlueprint(finalWf))
}
}]);
} catch (e) { /* silencioso */ }
};
// Chamado pelo modal ao conectar/desconectar um conector.
const onConnectorChanged = (connectorId, connected) => {
setConnectorCatalog((cat) => cat.map((c) => c.id === connectorId ? { ...c, connected } : c));
setConnectedIds((prev) => {
const next = new Set(prev);
if (connected) next.add(connectorId); else next.delete(connectorId);
// dispara auto-continuar se completou os pendentes
if (connected && pendingConn && pendingConn.required.every((id) => next.has(id))) {
const wf = pendingConn.workflow;
setPendingConn(null);
setTimeout(() => runContinue(wf), 250);
}
return next;
});
setConnectModal(null);
};
const send = async (text, attachments = []) => {
lastPromptRef.current = text;
const title = text || (attachments.length ? attachments[0].name : "");
if (!messages.length && title) setChatTitle(title.length > 42 ? title.slice(0, 42) + "…" : title);
const userId = nid(), thinkId = nid();
const convId = activeChat;
setMessages((m) => [...m,
{ id: userId, role: "user", text, attachments },
{ id: thinkId, role: "ai", thinking: true, thinkingText: thinkingOn ? "Pensando com raciocínio estendido…" : "Analisando seu pedido…" }
]);
try {
const model = modelChoice[provider] || (PROVIDERS[provider] && PROVIDERS[provider].model);
const res = await kbApi.authFetch("/api/chat/messages", {
method: "POST",
body: JSON.stringify({ conversationId: convId, text, provider, model, webSearch: webSearchOn, thinkingMode: thinkingOn,
attachments: (attachments || []).map((a) => ({ name: a.name, type: a.type, kind: a.kind, size: a.size, url: a.url })) })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Falha ao processar a mensagem.");
const a = data.assistant || {};
const wf = a.workflow;
const required = a.requiredConnections || [];
// CTA direto so quando nao falta conectar nada.
const cta = wf && required.length === 0 ? {
title: "Abrir no editor de workflows",
sub: `${(wf.nodes || []).length} nós · pronto para revisar`,
onClick: () => openEditor(mapBlueprint(wf))
} : null;
setMessages((m) => m.map((x) => x.id === thinkId
? {
id: thinkId, role: "ai", text: a.content || "",
charts: a.charts || [],
actions: a.actions || [],
tables: a.tables || [],
reportSections: a.reportSections || [],
dashboards: a.dashboards || [],
dashboardWidgets: a.dashboardWidgets || [],
via: model || (PROVIDERS[provider] && PROVIDERS[provider].model),
cta, connections: required, workflow: wf || null
}
: x));
if (wf && required.length > 0) {
setPendingConn({ required: required.map((r) => r.connectorId), workflow: wf });
}
const newId = data.conversationId;
setActiveChat(newId);
setChats((cs) => {
if (cs.some((c) => c.id === newId)) return cs;
return [{ id: newId, title: text.length > 48 ? text.slice(0, 48) + "…" : text, when: "Hoje", pinned: false, connectors: wf ? Array.from(new Set((wf.nodes || []).map((n) => aliasConnector(n.connectorId)))) : [] }, ...cs];
});
} catch (err) {
setMessages((m) => m.map((x) => x.id === thinkId
? { id: thinkId, role: "ai", text: err.message || "Não foi possível responder agora." }
: x));
}
};
sendRef.current = send;
// Permite que botoes de dashboard (action send_message) enviem ao chat.
if (typeof window !== "undefined") window.__kbSend = send;
const newChat = () => { setMessages([]); setActiveChat(null); setChatTitle("Novo chat"); setView("chats"); };
const selectChat = async (id) => {
const c = chats.find((x) => x.id === id);
setActiveChat(id); setChatTitle(c ? c.title : "Chat"); setView("chats");
setMessages([{ id: nid(), role: "ai", thinking: true, thinkingText: "Carregando conversa…" }]);
try {
const res = await kbApi.authFetch("/api/chat/messages?conversationId=" + encodeURIComponent(id));
const data = await res.json();
const msgs = (data.messages || []).map((m) => {
const msg = {
...messageArtifactsFromMetadata(m.metadata),
id: m.id,
role: m.role === "assistant" ? "ai" : "user",
text: m.content,
via: m.role === "assistant" ? (PROVIDERS[provider] && PROVIDERS[provider].model) : undefined
};
// Reconstroi o botao "Abrir no editor" ao reabrir o chat do historico.
if (m.role === "assistant" && m.workflowId) {
msg.cta = {
title: "Abrir no editor de workflows",
sub: "pronto para revisar",
onClick: () => openFlowById(m.workflowId)
};
}
return msg;
});
setMessages(msgs);
} catch (e) {
setMessages([]);
}
};
const pinChat = (id) => setChats((cs) => cs.map((c) => c.id === id ? { ...c, pinned: !c.pinned } : c));
const renameChat = (id, title) => { setChats((cs) => cs.map((c) => c.id === id ? { ...c, title } : c)); if (activeChat === id) setChatTitle(title); };
const deleteChat = (id) => {
setChats((cs) => cs.filter((c) => c.id !== id));
if (activeChat === id) newChat();
kbApi.authFetch("/api/chat/conversations?id=" + encodeURIComponent(id), { method: "DELETE" }).catch(() => {});
};
const deleteAllChats = async () => {
setChats([]); newChat();
try { await kbApi.authFetch("/api/chat/conversations", { method: "DELETE" }); } catch (e) {}
};
const nav = (id) => setView(id);
const railActive = view === "editor" ? "flows" : view;
if (!authReady) {
return (
);
}
if (!authed) return ;
const userName = firstName(user && user.name);
return (
setRailExpanded((e) => !e)}
onUpgrade={() => setView("upgrade")} user={user} onLogout={logout} />
{view === "chats" && (
<>
setView("upgrade")} collapsed={collapsed} onToggle={() => setCollapsed((c) => !c)}
planName={user && user.plan} />
{collapsed && (
setCollapsed(false)} data-tip="Abrir conversas">
)}
openFlowById(wf.id)} />
>
)}
{view === "editor" && (
setView("flows")} />
)}
{view === "flows" && openFlowById(f.id)} onNew={() => openEditor(BLANK_BLUEPRINT)} onDelete={deleteFlow} />}
{view === "connect" && }
{view === "runs" && }
{view === "models" && setModelChoice((s) => ({ ...s, [p]: m }))} />}
{view === "agents" && }
{view === "history" && }
{view === "settings" && setView("upgrade")} user={user} isAdmin={isAdmin} onModels={() => setView("models")} />}
{view === "upgrade" && setView("settings")} />}
{connectModal && (
setConnectModal(null)} />
)}
setTweak("homeLayout", v)} />
setTweak("accent", v)} />
setTweak("showTexture", v)} />
setTweak("loaderSpeed", v)} />
);
}
ReactDOM.createRoot(document.getElementById("root")).render( );