Compare commits

...

19 Commits

Author SHA1 Message Date
Jonas Pacheco 7528d4c36e feat(02-04): implementar versionamento Git-like de skills com rollback, fork e diff 2026-03-25 16:00:17 -03:00
Jonas Pacheco 9232666b8c fix: miroflow container conecta ao Ollama via ollama-net
- Remove extra_hosts (não funciona pois Ollama só escuta em 127.0.0.1)
- Adiciona rede ollama-net ao serviço miroflow
- OLLAMA_BASE_URL aponta para nome do container Ollama

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 14:34:03 -03:00
Jonas Pacheco 76adec5893 feat: MiroFlow containerizado no docker-compose
- Dockerfile.miroflow: imagem Python 3.11 com miroflow instalado via submodule
- docker-compose.prod.yml: novo serviço `miroflow` (porta 8006, rede interna)
- MIROFLOW_HOST atualizado de host fixo (10.0.7.1) para container `miroflow`
- miroflow_service.py: passa system_prompt e initial_user_message no AgentContext

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 14:22:39 -03:00
Jonas Pacheco 4ceb407505 feat(03-03): add Científico tab to BiWorkspace with MiroFlowControl
- Added Brain icon import from lucide-react
- Added MiroFlowControl component import
- Added TabsTrigger for 'cientifico' tab with Brain icon
- Added TabsContent rendering MiroFlowControl component
- All existing tabs (overview, datasources, upload, datasets, charts, backups, staging, advanced) preserved
2026-03-25 10:57:28 -03:00
Jonas Pacheco e5b915e7e8 feat(03-03): create MiroFlowControl component with agent selection and analysis interface
- Toggle for scientific mode with Brain icon
- Agent selector (Statistician, Fiscal Auditor, Researcher)
- Task textarea with dynamic placeholder per agent
- Analysis button with loading state (Loader2 animation)
- Result display with metadata (model, duration, execution_id)
- Error handling with readable messages
- Styled for dark theme matching BiWorkspace
2026-03-25 10:54:42 -03:00
Jonas Pacheco e4e809fa5b docs(03-01): atualizar SUMMARY.md com commits e desvios do plano 03-01
- Adicionar hashes de commit ao SUMMARY.md
- Documentar fallback llama3.2:3b para researcher
- Documentar conftest.py como desvio Rule 2
- Atualizar STATE.md com plano 03-01 concluido
- Atualizar ROADMAP.md progresso fase 3
2026-03-25 10:52:59 -03:00
Jonas Pacheco c14f238fe9 docs(03-01): criar SUMMARY.md do plano 03-01 2026-03-25 10:51:28 -03:00
Jonas Pacheco 76e1d34f5d fix(03-01): adicionar conftest.py para resolver imports em pytest do root
- conftest.py adiciona server/python ao sys.path
- Corrige ModuleNotFoundError ao rodar pytest da raiz do projeto
2026-03-25 10:50:54 -03:00
Jonas Pacheco 60f1c5cb1d feat(03-01): criar miroflow_service.py FastAPI porta 8006 com 3 agentes + testes pytest
- FastAPI microservico porta 8006 com agentes statistician, fiscal_auditor, researcher
- Configuracao de modelos: deepseek-r1:14b (stat/fiscal), llama3.1:8b com fallback 3b (researcher)
- OLLAMA_BASE_URL/v1 via UnifiedOpenAIClient
- test_miroflow_service.py: 6 testes cobrindo REQ-3.1 a REQ-3.4 (todos passam)
- sys.path.insert para submodule MiroFlow sem modificar o submodulo
2026-03-25 10:50:05 -03:00
Jonas Pacheco 071d74c7a3 docs(03-02): criar SUMMARY.md do plano 03-02 2026-03-25 10:45:15 -03:00
Jonas Pacheco 1a70e87cd4 feat(03-02): registrar MiroFlow routes em server/routes.ts
- Adiciona import de registerMiroFlowRoutes
- Chama registerMiroFlowRoutes(app) após registerBiEngineRoutes(app)
- POST /api/miroflow/analyze e GET /api/miroflow/health agora disponíveis

REQ-3.5, REQ-3.7 — Task 2/2 do plano 03-02
2026-03-25 10:41:19 -03:00
Jonas Pacheco d53be76474 feat(03-02): criar MiroFlow proxy TypeScript com timeout 300s e KG audit logging
- server/miroflow/engine-proxy.ts: proxy para :8006 com MIROFLOW_TIMEOUT=300_000ms
- server/miroflow/routes.ts: re-export de registerMiroFlowRoutes
- POST /api/miroflow/analyze requer autenticação (req.isAuthenticated())
- GET /api/miroflow/health sem autenticação
- registerExecutionInKG com auditHash SHA-256 via createNode
- KG failure não bloqueia resposta ao cliente

REQ-3.5, REQ-3.7 — Task 1/2 do plano 03-02
2026-03-25 10:41:12 -03:00
Jonas Pacheco 4057821404 feat(skills): Skill Marketplace (Biblioteca) — Fase 2
Servidor:
- GET /api/skills/marketplace: lista skills namespace=system/active,
  marca quais já foram importadas pelo tenant (campo imported)
- POST /api/skills/marketplace/:id/import: clona skill system→tenant
  com extends appontando para a origem

UI (Skills.tsx):
- Toggle "Minhas Skills" / "Biblioteca" no header
- Grid de cards 2 colunas com nome, slug, descrição, tags clicáveis,
  trigger type e versão
- Botão "Importar" → feedback imediato (badge "Importada" + highlight verde)
- Filtro por busca e tag na Biblioteca
- Estado vazio com instrução para admins publicarem skills system

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 09:44:49 -03:00
Jonas Pacheco 194a8dcc1b feat(skills): rota /skills + autocomplete de referências / no Monaco
- App.tsx: adiciona ProtectedRoute path="/skills"
- Skills.tsx: completion provider para referências no body editor
  - dispara ao digitar "/" em qualquer ponto do markdown
  - sugere todos os prefixos: /skill/, /skill:system/, /var/, /data/,
    /kg/, /file/, /tool/, /agent/
  - ao digitar /skill/ lista skills existentes do tenant com slug/desc
  - usa ref para evitar closure stale com skillsData
  - cleanup do provider no unmount

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 09:38:37 -03:00
Jonas Pacheco c9a0563422 feat(agentic): Fase 1-2 — Skills POO, Neo4j, UI editor e melhorias gerais
Arcádia Agentic Suite — Fase 1 (Fundação):
- shared/schema.ts: tabelas arcadia_skills + skill_executions (multi-tenant, herança, audit hash)
- docker-compose.yml: Neo4j 5 como profile 'kg' (porta 7474/7687)
- docker/litellm-config.yaml: ajustes de configuração

Arcádia Agentic Suite — Fase 2 (Skills Engine):
- client/src/pages/Skills.tsx: UI completa — lista, filtros, editor Monaco (Geral/Body/Params), executar e histórico
- client/src/App.tsx: import do componente Skills

Manus — melhorias de prompt e comportamento:
- server/manus/service.ts: system prompt com identidade Manus, regras PROATIVO, tabelas markdown
- server/manus/routes.ts: ajustes de rotas
- server/replit_integrations/chat/prompt.ts: prompt de chat aprimorado
- server/replit_integrations/chat/routes.ts: melhorias no fluxo de chat

Outros:
- Agent.tsx, ProcessCompass.tsx: melhorias de UI
- Correções menores em bi, blackboard, compass, ide, support, valuation, whatsapp

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 09:32:39 -03:00
Jonas Pacheco 3c61acf4d9 feat(xos): restaura forms de cadastro e adiciona seletor "Novo"
- XosCentral: botão "Novo" abre seletor (Contato / Negócio / Atividade)
- Restaura dialog de Novo Contato (perdido no rebuild anterior)
- Adiciona dialog de Nova Atividade (tipo, título, data, prioridade)
- Adiciona dialog de Novo Negócio (título, estágio, valor)
- Superset: logos v2 aplicadas via volume mount, retry de token, healthcheck 127.0.0.1
- xos_conversations: coluna status adicionada no banco

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 15:15:51 -03:00
Jonas Pacheco 9bfc888d28 feat(migration): suporte a upload de arquivos RAR, SQL e SQL.GZ
- Dockerfile: instala p7zip no Alpine para extração de RAR via 7z
- migration/routes.ts: detecta .rar, .sql.gz e .sql; extrai RAR com 7z,
  descomprime .gz com gunzip, analisa tabelas do dump SQL
- Migration.tsx: accept e hint atualizados para incluir RAR/SQL

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 12:00:13 -03:00
Jonas Pacheco 66d8dfbfcc feat: add MiroFlow and OpenClaw as git submodules
- server/modules/miroflow → github.com/MiroMindAI/MiroFlow
- server/modules/openclaw → github.com/miaoxworld/OpenClawInstaller

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 11:57:27 -03:00
Jonas Pacheco 1c6c3865e5 feat(skills): Fase 1 — SkillEngine POO + ReferenceParser + API REST
- server/skills/engine.ts: SkillEngine com herança declarativa (extends),
  composição via dependências, execução com audit hash SHA-256 e
  persistência em skill_executions
- server/skills/reference-parser.ts: parser e resolver de referências
  /skill/, /kg/, /var/, /tool/, /data/, /agent/, /file/ com interpolação
- server/skills/routes.ts: API REST completa (CRUD + execute + histórico)
- server/routes.ts: skills registradas + /api/health global
- migrations/0003_arcadia_skills.sql: tabelas arcadia_skills e
  skill_executions aplicadas em produção
- docker-compose.prod.yml: healthcheck via wget /api/health no serviço app

Parte do planejamento Arcádia Agentic Suite — Fase 1 (Fundação).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 11:30:51 -03:00
53 changed files with 3835 additions and 113 deletions

6
.gitmodules vendored Normal file
View File

@ -0,0 +1,6 @@
[submodule "server/modules/miroflow"]
path = server/modules/miroflow
url = https://github.com/MiroMindAI/MiroFlow.git
[submodule "server/modules/openclaw"]
path = server/modules/openclaw
url = https://github.com/miaoxworld/OpenClawInstaller.git

106
.planning/ROADMAP.md Normal file
View File

@ -0,0 +1,106 @@
# Roadmap: Arcádia Agentic Suite
## Overview
Evolução do Arcádia Suite de ERP tradicional para Sistema Agêntico Orientado a Objetos. Skills são objetos reutilizáveis (POO), Agentes instanciam Skills, Automações são composições orquestradas, Dev Center é a fábrica de agentes.
## Phases
- [x] **Phase 1: Fundação** - Infraestrutura base: submodules MiroFlow/OpenClaw, tabelas skills, Neo4j, ReferenceParser
- [~] **Phase 2: Skills Engine** - Skills criáveis e executáveis com editor Monaco e Marketplace
- [ ] **Phase 3: MiroFlow Embutido** - Análises científicas via agentes especializados + bridge Superset
- [ ] **Phase 4: OpenClaw Embutido** - Skills emergentes com detecção de padrões
- [ ] **Phase 5: Automation Fabric** - Automações unificadas (XOS + Central)
- [ ] **Phase 6: Dev Center Completo** - Fábrica de agentes: Design → Assemble → Deploy
## Phase Details
### Phase 1: Fundação
**Goal**: Infraestrutura base funcionando com submodules, banco, KG e parser de referências
**Depends on**: Nothing
**Success Criteria** (what must be TRUE):
1. Submodules MiroFlow e OpenClaw clonados e inicializados
2. Tabelas arcadia_skills e skill_executions existem no banco
3. Neo4j rodando via docker-compose
4. ReferenceParser parseia referências /skill/, /kg/, /file/ etc.
Plans:
- [x] 01-01: Submodules MiroFlow + OpenClaw
- [x] 01-02: Schema tabelas skills + migration
- [x] 01-03: Neo4j docker-compose + ReferenceParser
### Phase 2: Skills Engine
**Goal**: Skills criáveis, editáveis e executáveis com marketplace
**Depends on**: Phase 1
**Success Criteria** (what must be TRUE):
1. SkillEngine.ts suporta herança, composição e polimorfismo
2. API REST /skills CRUD funcionando
3. Editor Monaco com autocomplete de referências (/)
4. Skill Marketplace lista e filtra skills disponíveis
5. Versionamento Git-like de skills implementado
Plans:
- [x] 02-01: SkillEngine + API REST
- [x] 02-02: Editor Monaco + autocomplete + rota /skills
- [x] 02-03: Skill Marketplace (Biblioteca)
- [ ] 02-04: Versionamento Git-like de skills
### Phase 3: MiroFlow Embutido
**Goal**: Análises científicas disponíveis via agentes especializados integrados ao Superset
**Depends on**: Phase 2
**Success Criteria** (what must be TRUE):
1. MiroFlow configurado para Ollama local com modelos até 14B
2. Agente Statistician analisa dados SQL com deepseek-r1:14b
3. Agente Fiscal Auditor valida NFe/SPED com deepseek-r1:14b
4. Agente Researcher consulta KG com llama3.1:8b
5. Endpoint POST /api/miroflow/analyze retorna análise estruturada
6. MiroFlowControl.tsx toggle "Modo Científico" aparece no Superset
7. Execuções registradas com imutabilidade no KG
**Plans**: 3 planos
Plans:
- [x] 03-01-PLAN.md — Setup (ollama pull llama3.1:8b) + miroflow_service.py FastAPI porta 8006 com 3 agentes
- [x] 03-02-PLAN.md — Node bridge (engine-proxy.ts + routes.ts) + KG logging SHA-256
- [ ] 03-03-PLAN.md — Frontend MiroFlowControl.tsx + tab "Científico" em BiWorkspace.tsx
### Phase 4: OpenClaw Embutido
**Goal**: Skills emergentes criadas automaticamente a partir de padrões detectados
**Depends on**: Phase 3
**Success Criteria** (what must be TRUE):
1. PatternDetector detecta padrões (min 3 ocorrências, 30 dias, 80% confiança)
2. Skills emergentes criadas como DRAFT aguardando aprovação
3. Widget flutuante notifica usuário de sugestões
4. Fluxo completo: Padrão → DRAFT → Dev Center aprovação
**Plans**: TBD
### Phase 5: Automation Fabric
**Goal**: Automações unificadas sob runtime único substituindo XOS + Central
**Depends on**: Phase 4
**Success Criteria** (what must be TRUE):
1. 5 runtimes funcionando: WorkflowEngine, RuleEngine, AgentExecutor, ScheduleEngine, EventEngine
2. Automações existentes do XOS migradas sem perda de dados
3. Automações existentes do /automations Central migradas
4. AutomationCenter.tsx lista todas as automações unificadas
**Plans**: TBD
### Phase 6: Dev Center Completo
**Goal**: Fábrica completa de agentes: Design → Assemble → Deploy
**Depends on**: Phase 5
**Success Criteria** (what must be TRUE):
1. DesignStudio com modos UML, Visual Flow, Markdown Spec, Code Editor
2. AssembleLine gera código via Blackboard (GeneratorAgent)
3. OrchestrateCenter faz deploy, versionamento e monitoramento
4. Galeria de agentes por tenant funcional
**Plans**: TBD
## Progress
| Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------|
| 1. Fundação | 3/3 | Complete | 2026-03-25 |
| 2. Skills Engine | 3/4 | In progress | - |
| 3. MiroFlow Embutido | 2/3 | In Progress| |
| 4. OpenClaw Embutido | 0/TBD | Not started | - |
| 5. Automation Fabric | 0/TBD | Not started | - |
| 6. Dev Center Completo | 0/TBD | Not started | - |

18
.planning/STATE.md Normal file
View File

@ -0,0 +1,18 @@
# State
## Current Phase: 3 — MiroFlow Embutido
## Completed
- Phase 1: submodules, tabelas, Neo4j, ReferenceParser
- Phase 2: SkillEngine, API REST, Monaco Editor, /skills, autocomplete, Marketplace
## Completed Plans
- Phase 3, Plan 01: miroflow_service.py + tests (60f1c5c, 76e1d34)
## In Progress
- Phase 2 pendente: versionamento Git-like de skills (pode ser feito em paralelo ou movido para backlog)
## Notes
- Superset em produção com RLS configurado — não alterar sem confirmação
- Branch de deploy: `Servidor`
- Modelos Ollama precisam ser baixados: deepseek-r1:14b, llama3.1:8b (máximo 14B)

View File

@ -0,0 +1,48 @@
---
plan: "03-01"
phase: "03-miroflow-embutido"
status: complete
completed: 2026-03-25
---
# Summary: 03-01 — Python MiroFlow Microservice
## What was built
Microserviço FastAPI porta 8006 com 3 agentes científicos MiroFlow configurados para Ollama local, mais testes pytest cobrindo REQ-3.1 a REQ-3.4.
## Files created/modified
### Created
- `server/python/miroflow_service.py` — FastAPI app com make_agent_cfg(), run_agent(), GET /health, POST /analyze
- `server/python/test_miroflow_service.py` — 6 testes: health, statistician/fiscal_auditor/researcher config, request schema, 422 validation
## Test results
Todos os testes passaram (validados inline via python3):
- test_health OK (status: ok, service: miroflow)
- test_statistician_config OK (deepseek-r1:14b, base_url ends /v1)
- test_fiscal_auditor_config OK (deepseek-r1:14b)
- test_researcher_config OK (llama3.1:8b ou llama3.2:3b)
- test_analyze_request_validation OK (422 para agent invalido)
- test_analyze_request_schema OK
## Modelo Researcher
llama3.1:8b nao disponivel no Ollama local — fallback automatico para llama3.2:3b (ja instalado). Configuravel via MIROFLOW_RESEARCHER_MODEL no .env.
## Deps instaladas
omegaconf 2.3.0, hydra-core 1.3.2, mcp 1.26.0 instalados via pip3 --break-system-packages.
## Self-Check: PASSED
## Commits (03-01 execution)
- 60f1c5c: feat(03-01): criar miroflow_service.py FastAPI porta 8006 com 3 agentes + testes pytest
- 76e1d34: fix(03-01): adicionar conftest.py para resolver imports em pytest do root
## Deviations
- [Rule 2] conftest.py adicionado: pytest da raiz falhava sem sys.path fix
- [Documentado] llama3.2:3b usado como fallback para researcher (llama3.1:8b nao instalado)

View File

@ -0,0 +1,40 @@
---
plan: "03-02"
phase: "03-miroflow-embutido"
status: complete
completed: 2026-03-25
---
# Summary: 03-02 — Node.js MiroFlow Bridge
## What was built
Bridge Node.js → Python para o MiroFlow: proxy TypeScript com timeout 300s, rotas Express autenticadas e registro imutável de execuções no KG via auditHash SHA-256.
## Files created/modified
### Created
- `server/miroflow/engine-proxy.ts` — proxy para :8006, MIROFLOW_TIMEOUT=300_000ms, registerExecutionInKG com SHA-256
- `server/miroflow/routes.ts` — re-export de registerMiroFlowRoutes
### Modified
- `server/routes.ts` — import + chamada registerMiroFlowRoutes(app) após registerBiEngineRoutes
## Key decisions
- Timeout de 300_000ms (5 min) para POST /analyze — adequado para deepseek-r1:14b
- Health check usa timeout curto (5_000ms)
- KG failure não bloqueia resposta ao cliente (try/catch com console.error)
- auditHash cobre: execution_id + agent + model + input + output
## TypeScript compilation
Erros nos novos arquivos: **0**
(Erros pré-existentes em App.tsx e server/modules/miroflow/ não relacionados)
## Commits
- `d53be76` — feat(03-02): criar MiroFlow proxy TypeScript com timeout 300s e KG audit logging
- `1a70e87` — feat(03-02): registrar MiroFlow routes em server/routes.ts
## Self-Check: PASSED

View File

@ -10,6 +10,8 @@ RUN npm run build
FROM node:20-alpine AS runner
RUN apk add --no-cache p7zip
WORKDIR /app
COPY --from=builder /app/dist ./dist

24
Dockerfile.miroflow Normal file
View File

@ -0,0 +1,24 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Instalar MiroFlow e suas dependências direto do submodule
COPY server/modules/miroflow/ ./server/modules/miroflow/
RUN pip install --no-cache-dir -e ./server/modules/miroflow/
# Copiar o service script
COPY server/python/miroflow_service.py ./server/python/miroflow_service.py
ENV MIROFLOW_PORT=8006
ENV OLLAMA_BASE_URL=http://host.docker.internal:11434
EXPOSE 8006
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8006/health')" || exit 1
CMD ["python", "server/python/miroflow_service.py"]

View File

@ -70,6 +70,7 @@ const XosPipeline = lazy(() => import("@/pages/XosPipeline"));
const XosSupervisor = lazy(() => import("@/pages/XosSupervisor"));
const XosReports = lazy(() => import("@/pages/XosReports"));
const XosProtocols = lazy(() => import("@/pages/XosProtocols"));
const Skills = lazy(() => import("@/pages/Skills"));
function LoadingFallback() {
@ -146,6 +147,7 @@ function Router() {
<ProtectedRoute path="/page-builder" component={PageBuilder} />
<ProtectedRoute path="/migration" component={Migration} />
<ProtectedRoute path="/dev-center" component={DevCenter} />
<ProtectedRoute path="/skills" component={Skills} />
<ProtectedRoute path="/page/:id" component={WorkspacePage} />
<ProtectedRoute path="/app/:id" component={AppViewer} />
<Route path="/auth" component={AuthPage} />

View File

@ -0,0 +1,217 @@
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
import { Badge } from "@/components/ui/badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Brain, Loader2 } from "lucide-react";
interface AnalyzeRequest {
agent: "statistician" | "fiscal_auditor" | "researcher";
task: string;
context?: Record<string, unknown>;
}
interface AnalyzeResponse {
agent: string;
model: string;
result: string;
execution_id: string;
duration_ms: number;
}
const AGENTS = [
{
value: "statistician",
label: "Statistician",
model: "deepseek-r1:14b",
placeholder:
"Ex: Analise a distribuição de vendas por região no último trimestre",
},
{
value: "fiscal_auditor",
label: "Fiscal Auditor",
model: "deepseek-r1:14b",
placeholder:
"Ex: Verifique inconsistências nos registros NFe do CNPJ 12.345.678/0001-90",
},
{
value: "researcher",
label: "Researcher",
model: "llama3.1:8b",
placeholder:
"Ex: Quais fornecedores têm maior correlação com atrasos de entrega?",
},
] as const;
interface MiroFlowControlProps {
currentDashboardData?: Record<string, unknown>;
}
export function MiroFlowControl({
currentDashboardData,
}: MiroFlowControlProps) {
const [enabled, setEnabled] = useState(false);
const [selectedAgent, setSelectedAgent] = useState<
"statistician" | "fiscal_auditor" | "researcher"
>("statistician");
const [task, setTask] = useState("");
const mutation = useMutation({
mutationFn: async (request: AnalyzeRequest) => {
const response = await apiRequest("POST", "/api/miroflow/analyze", request);
return response.json() as Promise<AnalyzeResponse>;
},
});
const handleAnalyze = () => {
if (!task.trim()) {
return;
}
mutation.mutate({
agent: selectedAgent,
task,
context: currentDashboardData,
});
};
const currentAgentConfig = AGENTS.find((a) => a.value === selectedAgent);
return (
<div className="space-y-6">
<Card className="bg-[#1f334d] border border-[#c89b3c]/20">
<CardHeader className="border-b border-[#c89b3c]/20 pb-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Brain className="w-6 h-6 text-[#c89b3c]" />
<CardTitle className="text-white">Modo Científico</CardTitle>
</div>
<button
onClick={() => setEnabled(!enabled)}
className={`relative inline-flex h-8 w-14 items-center rounded-full transition-colors ${
enabled ? "bg-[#c89b3c]" : "bg-gray-600"
}`}
>
<span
className={`inline-block h-6 w-6 transform rounded-full bg-white transition-transform ${
enabled ? "translate-x-7" : "translate-x-1"
}`}
/>
</button>
</div>
</CardHeader>
{enabled && (
<CardContent className="space-y-4 pt-6">
{/* Agent Selection */}
<div>
<label className="block text-sm font-medium text-white mb-2">
Selecione o Agente
</label>
<Select value={selectedAgent} onValueChange={(value: any) => setSelectedAgent(value)}>
<SelectTrigger className="bg-[#2a4466] border border-[#c89b3c]/20 text-white">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-[#2a4466] border border-[#c89b3c]/20">
{AGENTS.map((agent) => (
<SelectItem key={agent.value} value={agent.value} className="text-white">
<div>
<div>{agent.label}</div>
<div className="text-xs text-gray-400">{agent.model}</div>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Task Input */}
<div>
<label className="block text-sm font-medium text-white mb-2">
Digite a Tarefa
</label>
<Textarea
placeholder={currentAgentConfig?.placeholder || "Digite sua tarefa..."}
value={task}
onChange={(e) => setTask(e.target.value)}
disabled={mutation.isPending}
className="bg-[#2a4466] border border-[#c89b3c]/20 text-white placeholder:text-gray-500 min-h-24"
/>
</div>
{/* Analyze Button */}
<Button
onClick={handleAnalyze}
disabled={!task.trim() || mutation.isPending}
className="w-full bg-[#c89b3c] hover:bg-[#d4a94a] text-[#1f334d] font-semibold"
>
{mutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Analisando...
</>
) : (
"Analisar"
)}
</Button>
{/* Error Display */}
{mutation.isError && (
<div className="rounded-lg p-4 bg-red-900/20 border border-red-500/50">
<Badge className="mb-2 bg-red-600 text-white">Erro</Badge>
<p className="text-sm text-red-100">
{mutation.error instanceof Error
? mutation.error.message
: "Erro ao processar a análise"}
</p>
</div>
)}
{/* Result Display */}
{mutation.isSuccess && (
<div className="space-y-3">
<div className="flex items-center gap-2 mb-2">
<Badge className="bg-green-600 text-white">Sucesso</Badge>
</div>
<div className="rounded-lg p-4 bg-[#2a4466] border border-[#c89b3c]/20">
<ScrollArea className="h-64 w-full pr-4">
<p className="text-white text-sm whitespace-pre-wrap">
{mutation.data?.result}
</p>
</ScrollArea>
</div>
{/* Metadata */}
<div className="grid grid-cols-3 gap-2 text-xs text-gray-400">
<div>
<span className="font-semibold text-[#c89b3c]">Modelo:</span>{" "}
{mutation.data?.model}
</div>
<div>
<span className="font-semibold text-[#c89b3c]">Tempo:</span>{" "}
{mutation.data?.duration_ms}ms
</div>
<div>
<span className="font-semibold text-[#c89b3c]">ID:</span>{" "}
{mutation.data?.execution_id?.slice(0, 8)}...
</div>
</div>
</div>
)}
</CardContent>
)}
</Card>
</div>
);
}

View File

@ -365,6 +365,14 @@ async function cancelManusRun(id: number): Promise<void> {
await fetch(`/api/manus/runs/${id}`, { method: "DELETE", credentials: "include" });
}
async function deleteManusRun(id: number): Promise<void> {
await fetch(`/api/manus/runs/${id}`, { method: "DELETE", credentials: "include" });
}
async function deleteAllManusRuns(): Promise<void> {
await fetch("/api/manus/runs", { method: "DELETE", credentials: "include" });
}
async function startManusRun(data: { prompt: string; attachedFiles?: AttachedFile[]; conversationHistory?: Array<{role: string; content: string}> }): Promise<{ runId: number }> {
const response = await fetch("/api/manus/run", {
method: "POST",
@ -1246,7 +1254,13 @@ export default function Agent() {
if (line.startsWith("data: ")) {
try {
const data = JSON.parse(line.slice(6));
if (data.tool_status) {
setCurrentToolName(data.tool_status);
setProcessingMode("using_tools");
}
if (data.content) {
setCurrentToolName(null);
setProcessingMode("idle");
fullContent += data.content;
setStreamingContent(fullContent);
}
@ -1485,8 +1499,21 @@ export default function Agent() {
Nova Tarefa
</Button>
</div>
<div className="px-3 pt-3 pb-1">
<div className="px-3 pt-3 pb-1 flex items-center justify-between">
<p className="text-xs text-white/40 uppercase tracking-wider font-medium">Todas as Tarefas</p>
{manusRuns.length > 0 && (
<button
onClick={async () => {
await deleteAllManusRuns();
setSelectedRun(null);
queryClient.invalidateQueries({ queryKey: ["manus-runs"] });
}}
className="text-[10px] text-white/30 hover:text-red-400 transition-colors"
title="Limpar histórico"
>
Limpar tudo
</button>
)}
</div>
<ScrollArea className="flex-1 px-2">
{loadingRuns ? (
@ -1498,22 +1525,35 @@ export default function Agent() {
) : (
<div className="space-y-1 pb-2">
{manusRuns.map((run) => (
<button
key={run.id}
onClick={() => setSelectedRun(run.id)}
className={`w-full text-left p-2.5 rounded-lg transition-all ${
selectedRun === run.id
? "bg-[#c89b3c]/20 border border-[#c89b3c]/50"
: "hover:bg-[#162638] border border-transparent"
}`}
data-testid={`run-${run.id}`}
>
<p className="text-xs text-white line-clamp-2 mb-1.5">{run.prompt}</p>
<div className="flex items-center gap-2">
{getStatusBadge(run.status)}
<span className="text-[10px] text-white/40">{formatTime(run.createdAt)}</span>
</div>
</button>
<div key={run.id} className="group relative">
<button
onClick={() => setSelectedRun(run.id)}
className={`w-full text-left p-2.5 rounded-lg transition-all pr-8 ${
selectedRun === run.id
? "bg-[#c89b3c]/20 border border-[#c89b3c]/50"
: "hover:bg-[#162638] border border-transparent"
}`}
data-testid={`run-${run.id}`}
>
<p className="text-xs text-white line-clamp-2 mb-1.5">{run.prompt}</p>
<div className="flex items-center gap-2">
{getStatusBadge(run.status)}
<span className="text-[10px] text-white/40">{formatTime(run.createdAt)}</span>
</div>
</button>
<button
onClick={async (e) => {
e.stopPropagation();
await deleteManusRun(run.id);
if (selectedRun === run.id) setSelectedRun(null);
queryClient.invalidateQueries({ queryKey: ["manus-runs"] });
}}
className="absolute right-2 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 text-white/30 hover:text-red-400 transition-all"
title="Excluir"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
)}

View File

@ -47,6 +47,7 @@ import {
X,
Sparkles,
MessageSquare,
Brain,
} from "lucide-react";
import { Textarea } from "@/components/ui/textarea";
import {
@ -81,6 +82,7 @@ import {
Cell,
} from "recharts";
import { SupersetDashboard } from "@/components/SupersetDashboard";
import { MiroFlowControl } from "@/components/MiroFlowControl";
interface BiStats {
dataSources: number;
@ -2893,6 +2895,9 @@ export default function BiWorkspace() {
<TabsTrigger value="advanced" className="data-[state=active]:bg-[#c89b3c] data-[state=active]:text-[#1f334d] text-white/70">
<Settings className="w-4 h-4 mr-2" /> Insights
</TabsTrigger>
<TabsTrigger value="cientifico" className="data-[state=active]:bg-[#c89b3c] data-[state=active]:text-[#1f334d] text-white/70">
<Brain className="w-4 h-4 mr-2" /> Científico
</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="mt-0">
@ -2936,6 +2941,9 @@ export default function BiWorkspace() {
<SupersetDashboard dashboardId="executive-summary" height="calc(100vh - 320px)" />
</div>
</TabsContent>
<TabsContent value="cientifico" className="mt-0">
<MiroFlowControl />
</TabsContent>
</Tabs>
</div>
</div>

View File

@ -275,7 +275,7 @@ export default function Migration() {
<div className="mt-2 border-2 border-dashed rounded-lg p-6 text-center">
<input
type="file"
accept=".zip,.json,.csv"
accept=".zip,.json,.csv,.rar,.sql,.sql.gz"
className="hidden"
id="backup-file"
onChange={e => setUploadFile(e.target.files?.[0] || null)}
@ -286,7 +286,7 @@ export default function Migration() {
<p className="mt-2 text-sm text-gray-500">
{uploadFile ? uploadFile.name : "Clique para selecionar"}
</p>
<p className="text-xs text-gray-400 mt-1">ZIP (MongoDB), JSON ou CSV</p>
<p className="text-xs text-gray-400 mt-1">ZIP (MongoDB), RAR, SQL, SQL.GZ, JSON ou CSV</p>
</label>
</div>
</div>

View File

@ -1855,7 +1855,7 @@ export default function ProcessCompass() {
data-testid="search-projects"
/>
</div>
<Button onClick={() => setShowNewProjectDialog(true)} disabled={clients.length === 0} data-testid="btn-new-project">
<Button onClick={() => setShowNewProjectDialog(true)} data-testid="btn-new-project">
<Plus className="h-4 w-4 mr-2" /> Novo Projeto
</Button>
</div>
@ -4600,18 +4600,32 @@ export default function ProcessCompass() {
</div>
<div className="space-y-2">
<Label htmlFor="project-client">Cliente</Label>
<Select name="clientId" required>
<SelectTrigger data-testid="select-project-client">
<SelectValue placeholder="Selecione um cliente" />
</SelectTrigger>
<SelectContent>
{clients.map(client => (
<SelectItem key={client.id} value={client.id.toString()}>
{client.name}
</SelectItem>
))}
</SelectContent>
</Select>
{clients.length === 0 ? (
<div className="rounded-md border border-yellow-200 bg-yellow-50 p-3 text-sm text-yellow-800">
Nenhum cliente cadastrado.{" "}
<button
type="button"
className="underline font-medium"
onClick={() => { setShowNewProjectDialog(false); setShowNewClientDialog(true); }}
>
Criar cliente
</button>{" "}
antes de criar um projeto.
</div>
) : (
<Select name="clientId" required>
<SelectTrigger data-testid="select-project-client">
<SelectValue placeholder="Selecione um cliente" />
</SelectTrigger>
<SelectContent>
{clients.map(client => (
<SelectItem key={client.id} value={client.id.toString()}>
{client.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<div className="space-y-2">
<Label htmlFor="project-description">Descrição</Label>

977
client/src/pages/Skills.tsx Normal file
View File

@ -0,0 +1,977 @@
import { BrowserFrame } from "@/components/Browser/BrowserFrame";
import { useState, useRef, useEffect } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Plus,
Play,
Trash2,
Pencil,
Loader2,
CheckCircle,
XCircle,
Clock,
Search,
Zap,
Code2,
ListChecks,
ChevronRight,
History,
Copy,
Store,
Download,
CheckCheck,
Tag,
Sparkles,
} from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import Editor from "@monaco-editor/react";
// ─── Types ────────────────────────────────────────────────────────────────────
interface Skill {
id: string;
name: string;
slug: string;
description: string | null;
namespace: string;
status: string;
triggerType: string | null;
version: string;
body: string | null;
parametersSchema: Record<string, unknown> | null;
tags: string[] | null;
extends: string[] | null;
createdAt: string;
updatedAt: string;
}
interface SkillExecution {
id: string;
skillId: string;
status: string;
triggeredBy: string;
inputParams: Record<string, unknown> | null;
outputResult: Record<string, unknown> | null;
errorMessage: string | null;
durationMs: number | null;
startedAt: string;
completedAt: string | null;
}
interface MarketplaceSkill extends Skill {
imported: boolean;
}
const EMPTY_SKILL = {
name: "",
slug: "",
description: "",
namespace: "tenant",
status: "draft",
triggerType: "manual",
body: "# Skill\n\nDescreva o comportamento desta skill aqui.\n\n## Entradas\n\n/var/input\n\n## Processamento\n\n...\n\n## Saída\n\n...",
parametersSchema: "{}",
tags: "",
extends: "",
};
// ─── Helpers ──────────────────────────────────────────────────────────────────
function statusBadge(status: string) {
const map: Record<string, string> = {
active: "bg-green-500/20 text-green-400 border-green-500/30",
draft: "bg-yellow-500/20 text-yellow-400 border-yellow-500/30",
archived: "bg-gray-500/20 text-gray-400 border-gray-500/30",
};
return map[status] ?? map.draft;
}
function execStatusIcon(status: string) {
if (status === "success") return <CheckCircle className="w-4 h-4 text-green-400" />;
if (status === "error") return <XCircle className="w-4 h-4 text-red-400" />;
return <Clock className="w-4 h-4 text-yellow-400 animate-spin" />;
}
function toSlug(name: string) {
return name
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_|_$/g, "");
}
// ─── Main Component ───────────────────────────────────────────────────────────
export default function Skills() {
const qc = useQueryClient();
const { toast } = useToast();
const [search, setSearch] = useState("");
const [nsFilter, setNsFilter] = useState("all");
const [statusFilter, setStatusFilter] = useState("all");
const [editOpen, setEditOpen] = useState(false);
const [editingSkill, setEditingSkill] = useState<Skill | null>(null);
const [form, setForm] = useState(EMPTY_SKILL);
const [execOpen, setExecOpen] = useState(false);
const [execSkill, setExecSkill] = useState<Skill | null>(null);
const [execParams, setExecParams] = useState("{}");
const [execResult, setExecResult] = useState<SkillExecution | null>(null);
const [historyOpen, setHistoryOpen] = useState(false);
const [historySkill, setHistorySkill] = useState<Skill | null>(null);
const [view, setView] = useState<"minhas" | "marketplace">("minhas");
const [mktSearch, setMktSearch] = useState("");
const [mktTag, setMktTag] = useState("all");
const [importedId, setImportedId] = useState<string | null>(null);
// Ref para skills — usado no completion provider sem closure stale
const skillsRef = useRef<Skill[]>([]);
const completionDisposable = useRef<{ dispose(): void } | null>(null);
// ── Queries ────────────────────────────────────────────────────────────────
const { data: skillsData, isLoading } = useQuery<{ skills: Skill[] }>({
queryKey: ["/api/skills", search, nsFilter, statusFilter],
queryFn: async () => {
const params = new URLSearchParams();
if (search) params.set("search", search);
if (nsFilter !== "all") params.set("namespace", nsFilter);
if (statusFilter !== "all") params.set("status", statusFilter);
const res = await fetch(`/api/skills?${params}`);
return res.json();
},
});
const { data: historyData } = useQuery<{ executions: SkillExecution[] }>({
queryKey: ["/api/skills", historySkill?.id, "executions"],
queryFn: async () => {
const res = await fetch(`/api/skills/${historySkill!.id}/executions?limit=20`);
return res.json();
},
enabled: !!historySkill && historyOpen,
});
const { data: mktData, isLoading: mktLoading } = useQuery<{ skills: MarketplaceSkill[] }>({
queryKey: ["/api/skills/marketplace", mktSearch, mktTag],
queryFn: async () => {
const params = new URLSearchParams();
if (mktSearch) params.set("search", mktSearch);
if (mktTag !== "all") params.set("tag", mktTag);
const res = await fetch(`/api/skills/marketplace?${params}`);
return res.json();
},
enabled: view === "marketplace",
});
// ── Mutations ──────────────────────────────────────────────────────────────
const saveMutation = useMutation({
mutationFn: async (data: typeof form) => {
let paramsSchema: Record<string, unknown> = {};
try { paramsSchema = JSON.parse(data.parametersSchema); } catch {}
const payload = {
name: data.name,
slug: data.slug || toSlug(data.name),
description: data.description || null,
namespace: data.namespace,
status: data.status,
triggerType: data.triggerType,
body: data.body,
parametersSchema: paramsSchema,
tags: data.tags ? data.tags.split(",").map(t => t.trim()).filter(Boolean) : [],
extends: data.extends ? data.extends.split(",").map(t => t.trim()).filter(Boolean) : [],
};
const url = editingSkill ? `/api/skills/${editingSkill.id}` : "/api/skills";
const method = editingSkill ? "PUT" : "POST";
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(await res.text());
return res.json();
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["/api/skills"] });
setEditOpen(false);
toast({ title: editingSkill ? "Skill atualizada" : "Skill criada" });
},
onError: (e: any) => toast({ title: "Erro", description: e.message, variant: "destructive" }),
});
const deleteMutation = useMutation({
mutationFn: async (id: string) => {
const res = await fetch(`/api/skills/${id}`, { method: "DELETE" });
if (!res.ok) throw new Error("Erro ao deletar");
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["/api/skills"] });
toast({ title: "Skill removida" });
},
onError: (e: any) => toast({ title: "Erro", description: e.message, variant: "destructive" }),
});
const importMutation = useMutation({
mutationFn: async (skillId: string) => {
const res = await fetch(`/api/skills/marketplace/${skillId}/import`, { method: "POST" });
if (!res.ok) throw new Error(await res.text());
return res.json() as Promise<{ skill: Skill }>;
},
onSuccess: (data) => {
setImportedId(data.skill.id);
qc.invalidateQueries({ queryKey: ["/api/skills/marketplace"] });
qc.invalidateQueries({ queryKey: ["/api/skills"] });
toast({ title: `"${data.skill.name}" importada para suas skills` });
},
onError: (e: any) => toast({ title: "Erro ao importar", description: e.message, variant: "destructive" }),
});
const executeMutation = useMutation({
mutationFn: async ({ skillId, params }: { skillId: string; params: string }) => {
let inputParams: Record<string, unknown> = {};
try { inputParams = JSON.parse(params); } catch {}
const res = await fetch(`/api/skills/${skillId}/execute`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ inputParams, triggeredBy: "manual" }),
});
return res.json() as Promise<SkillExecution>;
},
onSuccess: (data) => {
setExecResult(data);
qc.invalidateQueries({ queryKey: ["/api/skills"] });
},
onError: (e: any) => toast({ title: "Erro", description: e.message, variant: "destructive" }),
});
// ── Handlers ───────────────────────────────────────────────────────────────
function openNew() {
setEditingSkill(null);
setForm(EMPTY_SKILL);
setEditOpen(true);
}
function openEdit(skill: Skill) {
setEditingSkill(skill);
setForm({
name: skill.name,
slug: skill.slug,
description: skill.description ?? "",
namespace: skill.namespace,
status: skill.status,
triggerType: skill.triggerType ?? "manual",
body: skill.body ?? "",
parametersSchema: JSON.stringify(skill.parametersSchema ?? {}, null, 2),
tags: (skill.tags ?? []).join(", "),
extends: (skill.extends ?? []).join(", "),
});
setEditOpen(true);
}
function openExec(skill: Skill) {
setExecSkill(skill);
setExecParams("{}");
setExecResult(null);
setExecOpen(true);
}
function openHistory(skill: Skill) {
setHistorySkill(skill);
setHistoryOpen(true);
}
const skills = skillsData?.skills ?? [];
// Mantém ref atualizado para o completion provider
useEffect(() => { skillsRef.current = skills; }, [skills]);
// Cleanup do completion provider ao desmontar
useEffect(() => () => { completionDisposable.current?.dispose(); }, []);
// Handler do Monaco Body — registra completion provider de referências /
function handleBodyEditorMount(editor: unknown, monaco: any) {
completionDisposable.current?.dispose();
const PREFIXES = [
{ label: "/skill/", detail: "Skill do tenant atual" },
{ label: "/skill:system/", detail: "Skill global do sistema" },
{ label: "/skill:company/", detail: "Skill da empresa" },
{ label: "/var/", detail: "Variável de contexto ou parâmetro de entrada" },
{ label: "/data/", detail: "Dado dinâmico do banco" },
{ label: "/kg/", detail: "Nó do Knowledge Graph (Neo4j)" },
{ label: "/file/", detail: "Arquivo no storage" },
{ label: "/tool/", detail: "Tool disponível no Manus" },
{ label: "/agent/", detail: "Agente especialista" },
];
completionDisposable.current = monaco.languages.registerCompletionItemProvider("markdown", {
triggerCharacters: ["/"],
provideCompletionItems(model: any, position: any) {
const line = model.getValueInRange({
startLineNumber: position.lineNumber,
startColumn: 1,
endLineNumber: position.lineNumber,
endColumn: position.column,
});
const match = line.match(/\/([\w:\/\-.]*)$/);
if (!match) return { suggestions: [] };
const typed = match[1];
const startCol = position.column - typed.length - 1;
const range = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: startCol,
endColumn: position.column,
};
const CIK = monaco.languages.CompletionItemKind;
// Sugestões de prefixos
const prefixSuggestions = PREFIXES.map(p => ({
label: p.label,
kind: CIK.Reference,
detail: p.detail,
insertText: p.label,
sortText: "0_" + p.label,
range,
}));
// Sugestões de skills existentes quando digita /skill/...
const skillSuggestions: any[] = [];
if (typed.startsWith("skill/") || typed.startsWith("skill:")) {
const afterSkill = typed.replace(/^skill(:[^/]+)?\//, "");
skillsRef.current.forEach(s => {
const ref = `/skill/${s.namespace}/${s.slug}`;
if (!afterSkill || s.slug.includes(afterSkill) || s.name.toLowerCase().includes(afterSkill)) {
skillSuggestions.push({
label: ref,
kind: CIK.Variable,
detail: s.description ?? s.name,
documentation: `namespace: ${s.namespace} | status: ${s.status}`,
insertText: ref,
sortText: "1_" + ref,
range,
});
}
});
}
return { suggestions: [...prefixSuggestions, ...skillSuggestions] };
},
});
}
// ── Render ─────────────────────────────────────────────────────────────────
return (
<BrowserFrame>
<div className="flex flex-col h-full bg-[#0a0f1a] text-white">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-white/10">
<div>
<h1 className="text-lg font-semibold">Skills</h1>
<p className="text-xs text-muted-foreground">Objetos reutilizáveis herança, composição, polimorfismo</p>
</div>
<div className="flex items-center gap-2">
<div className="flex rounded-md border border-white/10 overflow-hidden">
<button
onClick={() => setView("minhas")}
className={`px-3 py-1.5 text-xs flex items-center gap-1.5 transition-colors ${view === "minhas" ? "bg-[#c89b3c] text-black font-medium" : "text-white/60 hover:text-white hover:bg-white/5"}`}
>
<Zap className="w-3.5 h-3.5" /> Minhas Skills
</button>
<button
onClick={() => setView("marketplace")}
className={`px-3 py-1.5 text-xs flex items-center gap-1.5 transition-colors ${view === "marketplace" ? "bg-[#c89b3c] text-black font-medium" : "text-white/60 hover:text-white hover:bg-white/5"}`}
>
<Store className="w-3.5 h-3.5" /> Biblioteca
</button>
</div>
{view === "minhas" && (
<Button size="sm" onClick={openNew} className="bg-[#c89b3c] hover:bg-[#d4a94a] text-black">
<Plus className="w-4 h-4 mr-1" /> Nova Skill
</Button>
)}
</div>
</div>
{/* ── VIEW: MINHAS SKILLS ──────────────────────────────────────────────── */}
{view === "minhas" && (<>
{/* Filters */}
<div className="flex gap-2 p-3 border-b border-white/10">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-2.5 w-3.5 h-3.5 text-muted-foreground" />
<Input
placeholder="Buscar skills..."
value={search}
onChange={e => setSearch(e.target.value)}
className="pl-8 h-8 text-sm bg-white/5 border-white/10"
/>
</div>
<Select value={nsFilter} onValueChange={setNsFilter}>
<SelectTrigger className="w-32 h-8 text-xs bg-white/5 border-white/10">
<SelectValue placeholder="Namespace" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos</SelectItem>
<SelectItem value="system">System</SelectItem>
<SelectItem value="tenant">Tenant</SelectItem>
<SelectItem value="company">Company</SelectItem>
<SelectItem value="user">User</SelectItem>
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-28 h-8 text-xs bg-white/5 border-white/10">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos</SelectItem>
<SelectItem value="active">Ativo</SelectItem>
<SelectItem value="draft">Rascunho</SelectItem>
<SelectItem value="archived">Arquivado</SelectItem>
</SelectContent>
</Select>
</div>
{/* List */}
<ScrollArea className="flex-1">
{isLoading ? (
<div className="flex items-center justify-center h-40">
<Loader2 className="w-6 h-6 animate-spin text-[#c89b3c]" />
</div>
) : skills.length === 0 ? (
<div className="flex flex-col items-center justify-center h-40 text-muted-foreground gap-2">
<Zap className="w-8 h-8 opacity-30" />
<p className="text-sm">Nenhuma skill encontrada</p>
<Button size="sm" variant="outline" onClick={openNew}>Criar primeira skill</Button>
</div>
) : (
<div className="p-3 space-y-2">
{skills.map(skill => (
<Card key={skill.id} className="bg-white/5 border-white/10 hover:bg-white/8 transition-colors">
<CardContent className="p-3">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-sm truncate">{skill.name}</span>
<Badge variant="outline" className={`text-[10px] px-1.5 py-0 ${statusBadge(skill.status)}`}>
{skill.status}
</Badge>
<Badge variant="outline" className="text-[10px] px-1.5 py-0 border-white/20 text-white/50">
{skill.namespace}
</Badge>
</div>
<p className="text-xs text-muted-foreground font-mono">/skill/{skill.namespace}/{skill.slug}</p>
{skill.description && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-1">{skill.description}</p>
)}
{skill.tags && skill.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1.5">
{skill.tags.map(tag => (
<span key={tag} className="text-[10px] bg-white/10 px-1.5 py-0.5 rounded">{tag}</span>
))}
</div>
)}
</div>
<div className="flex items-center gap-1 flex-shrink-0">
<Button
size="icon"
variant="ghost"
className="h-7 w-7 hover:bg-green-500/20 hover:text-green-400"
onClick={() => openExec(skill)}
title="Executar"
>
<Play className="w-3.5 h-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 hover:bg-blue-500/20 hover:text-blue-400"
onClick={() => openHistory(skill)}
title="Histórico"
>
<History className="w-3.5 h-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 hover:bg-white/10"
onClick={() => openEdit(skill)}
title="Editar"
>
<Pencil className="w-3.5 h-3.5" />
</Button>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 hover:bg-red-500/20 hover:text-red-400"
onClick={() => deleteMutation.mutate(skill.id)}
title="Deletar"
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
</ScrollArea>
</>)}
{/* ── VIEW: BIBLIOTECA (MARKETPLACE) ──────────────────────────────────── */}
{view === "marketplace" && (<>
{/* Marketplace filters */}
<div className="flex gap-2 p-3 border-b border-white/10">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-2.5 w-3.5 h-3.5 text-muted-foreground" />
<Input
placeholder="Buscar na biblioteca..."
value={mktSearch}
onChange={e => setMktSearch(e.target.value)}
className="pl-8 h-8 text-sm bg-white/5 border-white/10"
/>
</div>
<Select value={mktTag} onValueChange={setMktTag}>
<SelectTrigger className="w-36 h-8 text-xs bg-white/5 border-white/10">
<Tag className="w-3 h-3 mr-1" />
<SelectValue placeholder="Tag" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todas as tags</SelectItem>
<SelectItem value="financeiro">Financeiro</SelectItem>
<SelectItem value="crm">CRM</SelectItem>
<SelectItem value="relatorio">Relatório</SelectItem>
<SelectItem value="automacao">Automação</SelectItem>
<SelectItem value="ia">IA</SelectItem>
</SelectContent>
</Select>
</div>
{/* Marketplace grid */}
<ScrollArea className="flex-1">
{mktLoading ? (
<div className="flex items-center justify-center h-40">
<Loader2 className="w-6 h-6 animate-spin text-[#c89b3c]" />
</div>
) : !mktData?.skills?.length ? (
<div className="flex flex-col items-center justify-center h-60 text-muted-foreground gap-3">
<Store className="w-12 h-12 opacity-20" />
<p className="text-sm font-medium">Biblioteca vazia</p>
<p className="text-xs text-center max-w-xs opacity-70">
Ainda não skills do sistema publicadas. Skills com namespace <code className="bg-white/10 px-1 rounded">system</code> e status <code className="bg-white/10 px-1 rounded">active</code> aparecerão aqui.
</p>
</div>
) : (
<div className="p-4 grid grid-cols-1 gap-3 sm:grid-cols-2">
{mktData.skills.map(skill => (
<Card
key={skill.id}
className={`border transition-all ${importedId === skill.id ? "border-green-500/40 bg-green-500/5" : "border-white/10 bg-white/5 hover:bg-white/8"}`}
>
<CardContent className="p-4">
{/* Top row */}
<div className="flex items-start justify-between gap-2 mb-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<Sparkles className="w-3.5 h-3.5 text-[#c89b3c] flex-shrink-0" />
<span className="font-semibold text-sm truncate">{skill.name}</span>
</div>
<p className="text-[10px] font-mono text-white/40">/skill:system/{skill.slug}</p>
</div>
{/* Import button */}
{skill.imported || importedId === skill.id ? (
<Badge variant="outline" className="text-[10px] border-green-500/40 text-green-400 bg-green-500/10 flex-shrink-0 gap-1">
<CheckCheck className="w-3 h-3" /> Importada
</Badge>
) : (
<Button
size="sm"
variant="outline"
className="h-7 text-xs border-[#c89b3c]/40 text-[#c89b3c] hover:bg-[#c89b3c]/10 flex-shrink-0 gap-1"
onClick={() => importMutation.mutate(skill.id)}
disabled={importMutation.isPending && importMutation.variables === skill.id}
>
{importMutation.isPending && importMutation.variables === skill.id
? <Loader2 className="w-3 h-3 animate-spin" />
: <Download className="w-3 h-3" />
}
Importar
</Button>
)}
</div>
{/* Description */}
{skill.description && (
<p className="text-xs text-white/60 mb-3 line-clamp-2">{skill.description}</p>
)}
{/* Tags */}
{skill.tags && skill.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{skill.tags.map(tag => (
<span
key={tag}
onClick={() => setMktTag(tag)}
className="text-[10px] bg-white/10 hover:bg-white/20 px-2 py-0.5 rounded cursor-pointer transition-colors"
>
{tag}
</span>
))}
</div>
)}
{/* Footer */}
<div className="flex items-center gap-2 mt-3 pt-2.5 border-t border-white/10">
<Badge variant="outline" className="text-[10px] px-1.5 py-0 border-white/20 text-white/40">
{skill.triggerType ?? "manual"}
</Badge>
<span className="text-[10px] text-white/30 ml-auto">v{skill.version}</span>
</div>
</CardContent>
</Card>
))}
</div>
)}
</ScrollArea>
</>)}
</div>
{/* ── Edit / Create Dialog ────────────────────────────────────────────── */}
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent className="max-w-3xl h-[85vh] flex flex-col bg-[#0f1729] border-white/10 text-white">
<DialogHeader>
<DialogTitle>{editingSkill ? "Editar Skill" : "Nova Skill"}</DialogTitle>
</DialogHeader>
<Tabs defaultValue="form" className="flex-1 flex flex-col min-h-0">
<TabsList className="bg-white/5 border border-white/10 w-fit">
<TabsTrigger value="form" className="text-xs gap-1">
<ListChecks className="w-3.5 h-3.5" /> Geral
</TabsTrigger>
<TabsTrigger value="body" className="text-xs gap-1">
<Code2 className="w-3.5 h-3.5" /> Body
</TabsTrigger>
<TabsTrigger value="params" className="text-xs gap-1">
<ChevronRight className="w-3.5 h-3.5" /> Parâmetros
</TabsTrigger>
</TabsList>
{/* Tab: Geral */}
<TabsContent value="form" className="flex-1 overflow-auto mt-3 space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs">Nome *</Label>
<Input
value={form.name}
onChange={e => setForm(f => ({ ...f, name: e.target.value, slug: toSlug(e.target.value) }))}
placeholder="Relatório de Vendas"
className="bg-white/5 border-white/10 text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Slug</Label>
<Input
value={form.slug}
onChange={e => setForm(f => ({ ...f, slug: e.target.value }))}
placeholder="relatorio_vendas"
className="bg-white/5 border-white/10 text-sm font-mono"
/>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs">Descrição</Label>
<Textarea
value={form.description}
onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
rows={2}
className="bg-white/5 border-white/10 text-sm resize-none"
/>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1">
<Label className="text-xs">Namespace</Label>
<Select value={form.namespace} onValueChange={v => setForm(f => ({ ...f, namespace: v }))}>
<SelectTrigger className="bg-white/5 border-white/10 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="system">system</SelectItem>
<SelectItem value="tenant">tenant</SelectItem>
<SelectItem value="company">company</SelectItem>
<SelectItem value="user">user</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">Status</Label>
<Select value={form.status} onValueChange={v => setForm(f => ({ ...f, status: v }))}>
<SelectTrigger className="bg-white/5 border-white/10 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">draft</SelectItem>
<SelectItem value="active">active</SelectItem>
<SelectItem value="archived">archived</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">Trigger</Label>
<Select value={form.triggerType} onValueChange={v => setForm(f => ({ ...f, triggerType: v }))}>
<SelectTrigger className="bg-white/5 border-white/10 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="manual">manual</SelectItem>
<SelectItem value="schedule">schedule</SelectItem>
<SelectItem value="event">event</SelectItem>
<SelectItem value="webhook">webhook</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs">Tags (separadas por vírgula)</Label>
<Input
value={form.tags}
onChange={e => setForm(f => ({ ...f, tags: e.target.value }))}
placeholder="financeiro, relatório, automático"
className="bg-white/5 border-white/10 text-sm"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Herança extends (slugs separados por vírgula)</Label>
<Input
value={form.extends}
onChange={e => setForm(f => ({ ...f, extends: e.target.value }))}
placeholder="/skill/system/base_report, /skill/tenant/financeiro"
className="bg-white/5 border-white/10 text-sm font-mono"
/>
</div>
</TabsContent>
{/* Tab: Body (Monaco) */}
<TabsContent value="body" className="flex-1 min-h-0 mt-3">
<div className="h-full rounded border border-white/10 overflow-hidden">
<Editor
height="100%"
defaultLanguage="markdown"
value={form.body}
onChange={v => setForm(f => ({ ...f, body: v ?? "" }))}
onMount={handleBodyEditorMount}
theme="vs-dark"
options={{
fontSize: 13,
minimap: { enabled: false },
wordWrap: "on",
lineNumbers: "on",
scrollBeyondLastLine: false,
padding: { top: 8 },
quickSuggestions: { other: true, comments: true, strings: true },
suggestOnTriggerCharacters: true,
}}
/>
</div>
</TabsContent>
{/* Tab: Params (JSON Schema) */}
<TabsContent value="params" className="flex-1 min-h-0 mt-3">
<div className="space-y-2 h-full flex flex-col">
<p className="text-xs text-muted-foreground">
JSON Schema dos parâmetros de entrada. Referenciáveis no body como <code className="bg-white/10 px-1 rounded">/var/nome_param</code>.
</p>
<div className="flex-1 rounded border border-white/10 overflow-hidden">
<Editor
height="100%"
defaultLanguage="json"
value={form.parametersSchema}
onChange={v => setForm(f => ({ ...f, parametersSchema: v ?? "{}" }))}
theme="vs-dark"
options={{
fontSize: 13,
minimap: { enabled: false },
formatOnType: true,
scrollBeyondLastLine: false,
padding: { top: 8 },
}}
/>
</div>
</div>
</TabsContent>
</Tabs>
<div className="flex justify-end gap-2 pt-3 border-t border-white/10 mt-2">
<Button variant="ghost" onClick={() => setEditOpen(false)}>Cancelar</Button>
<Button
onClick={() => saveMutation.mutate(form)}
disabled={saveMutation.isPending || !form.name}
className="bg-[#c89b3c] hover:bg-[#d4a94a] text-black"
>
{saveMutation.isPending && <Loader2 className="w-4 h-4 mr-1 animate-spin" />}
{editingSkill ? "Salvar" : "Criar"}
</Button>
</div>
</DialogContent>
</Dialog>
{/* ── Execute Dialog ──────────────────────────────────────────────────── */}
<Dialog open={execOpen} onOpenChange={setExecOpen}>
<DialogContent className="max-w-lg bg-[#0f1729] border-white/10 text-white">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Play className="w-4 h-4 text-green-400" />
Executar: {execSkill?.name}
</DialogTitle>
</DialogHeader>
<div className="space-y-3">
<div>
<Label className="text-xs mb-1 block">Parâmetros de entrada (JSON)</Label>
<div className="rounded border border-white/10 overflow-hidden h-32">
<Editor
height="100%"
defaultLanguage="json"
value={execParams}
onChange={v => setExecParams(v ?? "{}")}
theme="vs-dark"
options={{ fontSize: 12, minimap: { enabled: false }, scrollBeyondLastLine: false, padding: { top: 4 } }}
/>
</div>
</div>
{execResult && (
<div className="rounded border border-white/10 p-3 space-y-2">
<div className="flex items-center gap-2 text-sm font-medium">
{execStatusIcon(execResult.status)}
{execResult.status === "success" ? "Executado com sucesso" : "Erro na execução"}
{execResult.durationMs && (
<span className="text-xs text-muted-foreground ml-auto">{execResult.durationMs}ms</span>
)}
</div>
{execResult.status === "error" && execResult.errorMessage && (
<p className="text-xs text-red-400 font-mono">{execResult.errorMessage}</p>
)}
{execResult.outputResult && (
<ScrollArea className="h-40">
<pre className="text-xs text-green-300 font-mono whitespace-pre-wrap">
{JSON.stringify(execResult.outputResult, null, 2)}
</pre>
</ScrollArea>
)}
<Button
size="sm"
variant="ghost"
className="text-xs"
onClick={() => navigator.clipboard.writeText(JSON.stringify(execResult.outputResult, null, 2))}
>
<Copy className="w-3 h-3 mr-1" /> Copiar resultado
</Button>
</div>
)}
</div>
<div className="flex justify-end gap-2 pt-2 border-t border-white/10">
<Button variant="ghost" size="sm" onClick={() => setExecOpen(false)}>Fechar</Button>
<Button
size="sm"
onClick={() => execSkill && executeMutation.mutate({ skillId: execSkill.id, params: execParams })}
disabled={executeMutation.isPending}
className="bg-green-600 hover:bg-green-700 text-white"
>
{executeMutation.isPending
? <><Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> Executando...</>
: <><Play className="w-3.5 h-3.5 mr-1" /> Executar</>
}
</Button>
</div>
</DialogContent>
</Dialog>
{/* ── History Dialog ──────────────────────────────────────────────────── */}
<Dialog open={historyOpen} onOpenChange={setHistoryOpen}>
<DialogContent className="max-w-2xl bg-[#0f1729] border-white/10 text-white">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<History className="w-4 h-4 text-blue-400" />
Histórico: {historySkill?.name}
</DialogTitle>
</DialogHeader>
<ScrollArea className="h-80">
{!historyData ? (
<div className="flex items-center justify-center h-20">
<Loader2 className="w-5 h-5 animate-spin text-[#c89b3c]" />
</div>
) : historyData.executions.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">Nenhuma execução registrada</p>
) : (
<div className="space-y-2 p-1">
{historyData.executions.map(ex => (
<div key={ex.id} className="flex items-start gap-3 p-2.5 rounded bg-white/5 border border-white/10">
<div className="mt-0.5">{execStatusIcon(ex.status)}</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-mono text-muted-foreground truncate">{ex.id.slice(0, 8)}...</span>
<Badge variant="outline" className="text-[10px] px-1 border-white/20 text-white/50">
{ex.triggeredBy}
</Badge>
{ex.durationMs && (
<span className="text-[10px] text-muted-foreground ml-auto">{ex.durationMs}ms</span>
)}
</div>
{ex.errorMessage && (
<p className="text-xs text-red-400 font-mono truncate">{ex.errorMessage}</p>
)}
<p className="text-[10px] text-muted-foreground">
{new Date(ex.startedAt).toLocaleString("pt-BR")}
</p>
</div>
</div>
))}
</div>
)}
</ScrollArea>
</DialogContent>
</Dialog>
</BrowserFrame>
);
}

View File

@ -1,5 +1,5 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Link } from "wouter";
import { BrowserFrame } from "@/components/Browser/BrowserFrame";
import {
@ -13,6 +13,8 @@ import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
interface XosStats {
total_contacts: number;
@ -66,6 +68,68 @@ interface Activity {
export default function XosCentral() {
const [activeTab, setActiveTab] = useState("dashboard");
const [isNewContactOpen, setIsNewContactOpen] = useState(false);
const [newContact, setNewContact] = useState({ name: "", email: "", phone: "", company: "", position: "" });
const [isNewActivityOpen, setIsNewActivityOpen] = useState(false);
const [newActivity, setNewActivity] = useState({ type: "task", title: "", description: "", due_at: "", priority: "normal" });
const [isNewSelectorOpen, setIsNewSelectorOpen] = useState(false);
const [isNewDealOpen, setIsNewDealOpen] = useState(false);
const [newDeal, setNewDeal] = useState({ title: "", pipeline_id: "1", stage_id: "1", value: "" });
const queryClient = useQueryClient();
const createContactMutation = useMutation({
mutationFn: async (data: typeof newContact) => {
const res = await fetch("/api/xos/contacts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error("Erro ao criar contato");
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/xos/contacts"] });
queryClient.invalidateQueries({ queryKey: ["/api/xos/stats"] });
setIsNewContactOpen(false);
setNewContact({ name: "", email: "", phone: "", company: "", position: "" });
},
});
const createDealMutation = useMutation({
mutationFn: async (data: typeof newDeal) => {
const res = await fetch("/api/xos/deals", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error("Erro ao criar negócio");
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/xos/deals"] });
queryClient.invalidateQueries({ queryKey: ["/api/xos/stats"] });
setIsNewDealOpen(false);
setNewDeal({ title: "", pipeline_id: "1", stage_id: "1", value: "" });
},
});
const createActivityMutation = useMutation({
mutationFn: async (data: typeof newActivity) => {
const res = await fetch("/api/xos/activities", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error("Erro ao criar atividade");
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/xos/activities"] });
queryClient.invalidateQueries({ queryKey: ["/api/xos/stats"] });
setIsNewActivityOpen(false);
setNewActivity({ type: "task", title: "", description: "", due_at: "", priority: "normal" });
},
});
const { data: stats } = useQuery<XosStats>({
queryKey: ["/api/xos/stats"],
@ -177,7 +241,7 @@ export default function XosCentral() {
</span>
)}
</Button>
<Button data-testid="button-new-contact">
<Button data-testid="button-new-contact" onClick={() => setIsNewSelectorOpen(true)}>
<PlusCircle className="h-4 w-4 mr-2" />
Novo
</Button>
@ -406,7 +470,7 @@ export default function XosCentral() {
<Filter className="h-4 w-4 mr-2" />
Filtros
</Button>
<Button size="sm">
<Button size="sm" onClick={() => setIsNewContactOpen(true)}>
<PlusCircle className="h-4 w-4 mr-2" />
Novo Contato
</Button>
@ -478,7 +542,7 @@ export default function XosCentral() {
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Atividades</CardTitle>
<Button size="sm">
<Button size="sm" onClick={() => setIsNewActivityOpen(true)}>
<PlusCircle className="h-4 w-4 mr-2" />
Nova Atividade
</Button>
@ -520,6 +584,175 @@ export default function XosCentral() {
</Tabs>
</main>
</div>
{/* Seletor: o que deseja criar? */}
<Dialog open={isNewSelectorOpen} onOpenChange={setIsNewSelectorOpen}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>O que deseja criar?</DialogTitle>
</DialogHeader>
<div className="grid gap-3 pt-2">
<button
className="flex items-center gap-4 p-4 rounded-lg border hover:bg-blue-50 hover:border-blue-300 transition-colors text-left"
onClick={() => { setIsNewSelectorOpen(false); setIsNewContactOpen(true); }}
>
<div className="bg-blue-100 p-2 rounded-lg"><Users className="h-5 w-5 text-blue-600" /></div>
<div><p className="font-semibold text-slate-800">Contato</p><p className="text-sm text-slate-500">Adicionar lead ou cliente</p></div>
</button>
<button
className="flex items-center gap-4 p-4 rounded-lg border hover:bg-emerald-50 hover:border-emerald-300 transition-colors text-left"
onClick={() => { setIsNewSelectorOpen(false); setIsNewDealOpen(true); }}
>
<div className="bg-emerald-100 p-2 rounded-lg"><TrendingUp className="h-5 w-5 text-emerald-600" /></div>
<div><p className="font-semibold text-slate-800">Negócio</p><p className="text-sm text-slate-500">Criar oportunidade de venda</p></div>
</button>
<button
className="flex items-center gap-4 p-4 rounded-lg border hover:bg-violet-50 hover:border-violet-300 transition-colors text-left"
onClick={() => { setIsNewSelectorOpen(false); setIsNewActivityOpen(true); }}
>
<div className="bg-violet-100 p-2 rounded-lg"><Calendar className="h-5 w-5 text-violet-600" /></div>
<div><p className="font-semibold text-slate-800">Atividade</p><p className="text-sm text-slate-500">Agendar tarefa ou reunião</p></div>
</button>
</div>
</DialogContent>
</Dialog>
{/* Dialog Novo Negócio */}
<Dialog open={isNewDealOpen} onOpenChange={setIsNewDealOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Novo Negócio</DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-2">
<div>
<Label>Título *</Label>
<Input placeholder="Ex: Proposta comercial Empresa X" value={newDeal.title} onChange={(e) => setNewDeal({ ...newDeal, title: e.target.value })} />
</div>
<div>
<Label>Estágio</Label>
<select className="w-full mt-1 border rounded-md px-3 py-2 text-sm" value={newDeal.stage_id} onChange={(e) => setNewDeal({ ...newDeal, stage_id: e.target.value })}>
<option value="1">Prospecção</option>
<option value="2">Qualificação</option>
<option value="3">Proposta</option>
<option value="4">Negociação</option>
<option value="5">Ganho</option>
<option value="6">Perdido</option>
</select>
</div>
<div>
<Label>Valor (R$)</Label>
<Input type="number" placeholder="0,00" value={newDeal.value} onChange={(e) => setNewDeal({ ...newDeal, value: e.target.value })} />
</div>
<div className="flex gap-2 pt-2">
<Button variant="outline" className="flex-1" onClick={() => setIsNewDealOpen(false)}>Cancelar</Button>
<Button
className="flex-1"
onClick={() => createDealMutation.mutate(newDeal)}
disabled={!newDeal.title || createDealMutation.isPending}
>
{createDealMutation.isPending ? "Salvando..." : "Salvar Negócio"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<Dialog open={isNewContactOpen} onOpenChange={setIsNewContactOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Novo Contato</DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-2">
<div>
<Label>Nome *</Label>
<Input placeholder="Nome completo" value={newContact.name} onChange={(e) => setNewContact({ ...newContact, name: e.target.value })} />
</div>
<div>
<Label>E-mail</Label>
<Input placeholder="email@empresa.com" value={newContact.email} onChange={(e) => setNewContact({ ...newContact, email: e.target.value })} />
</div>
<div>
<Label>Telefone / WhatsApp</Label>
<Input placeholder="(11) 99999-9999" value={newContact.phone} onChange={(e) => setNewContact({ ...newContact, phone: e.target.value })} />
</div>
<div>
<Label>Empresa</Label>
<Input placeholder="Nome da empresa" value={newContact.company} onChange={(e) => setNewContact({ ...newContact, company: e.target.value })} />
</div>
<div>
<Label>Cargo</Label>
<Input placeholder="Cargo ou função" value={newContact.position} onChange={(e) => setNewContact({ ...newContact, position: e.target.value })} />
</div>
<div className="flex gap-2 pt-2">
<Button variant="outline" className="flex-1" onClick={() => setIsNewContactOpen(false)}>Cancelar</Button>
<Button
className="flex-1"
onClick={() => createContactMutation.mutate(newContact)}
disabled={!newContact.name || createContactMutation.isPending}
>
{createContactMutation.isPending ? "Salvando..." : "Salvar Contato"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<Dialog open={isNewActivityOpen} onOpenChange={setIsNewActivityOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Nova Atividade</DialogTitle>
</DialogHeader>
<div className="space-y-4 pt-2">
<div>
<Label>Tipo</Label>
<select
className="w-full mt-1 border rounded-md px-3 py-2 text-sm"
value={newActivity.type}
onChange={(e) => setNewActivity({ ...newActivity, type: e.target.value })}
>
<option value="task">Tarefa</option>
<option value="call">Ligação</option>
<option value="email">E-mail</option>
<option value="meeting">Reunião</option>
</select>
</div>
<div>
<Label>Título *</Label>
<Input placeholder="Descreva a atividade" value={newActivity.title} onChange={(e) => setNewActivity({ ...newActivity, title: e.target.value })} />
</div>
<div>
<Label>Descrição</Label>
<Input placeholder="Detalhes adicionais" value={newActivity.description} onChange={(e) => setNewActivity({ ...newActivity, description: e.target.value })} />
</div>
<div>
<Label>Data prevista</Label>
<Input type="datetime-local" value={newActivity.due_at} onChange={(e) => setNewActivity({ ...newActivity, due_at: e.target.value })} />
</div>
<div>
<Label>Prioridade</Label>
<select
className="w-full mt-1 border rounded-md px-3 py-2 text-sm"
value={newActivity.priority}
onChange={(e) => setNewActivity({ ...newActivity, priority: e.target.value })}
>
<option value="low">Baixa</option>
<option value="normal">Normal</option>
<option value="high">Alta</option>
</select>
</div>
<div className="flex gap-2 pt-2">
<Button variant="outline" className="flex-1" onClick={() => setIsNewActivityOpen(false)}>Cancelar</Button>
<Button
className="flex-1"
onClick={() => createActivityMutation.mutate(newActivity)}
disabled={!newActivity.title || createActivityMutation.isPending}
>
{createActivityMutation.isPending ? "Salvando..." : "Salvar Atividade"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</BrowserFrame>
);
}

View File

@ -77,6 +77,15 @@ services:
ERPNEXT_URL: ${ERPNEXT_URL:-http://erpnext:8080}
ERPNEXT_API_KEY: ${ERPNEXT_API_KEY:-}
ERPNEXT_API_SECRET: ${ERPNEXT_API_SECRET:-}
# ── MiroFlow (container) ─────────────────────────────────────────────
MIROFLOW_HOST: "miroflow"
MIROFLOW_PORT: "8006"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:5000/api/health || exit 1"]
interval: 30s
timeout: 10s
retries: 5
start_period: 60s
ports:
- "5000:5000"
depends_on:
@ -195,6 +204,19 @@ services:
- arcadia-internal
# ── MiroFlow (agente científico) ─────────────────────────────────────────────
miroflow:
build:
context: .
dockerfile: Dockerfile.miroflow
restart: always
environment:
MIROFLOW_PORT: "8006"
OLLAMA_BASE_URL: "http://ollama-ia1upsekrad96at5hq97e4qa:11434"
networks:
- arcadia-internal
- ollama-net
# ── Microserviços Python ─────────────────────────────────────────────────────
contabil:
build:
@ -366,6 +388,11 @@ services:
volumes:
- ./docker/superset/superset_config.py:/app/pythonpath/superset_config.py:ro
- ./docker/superset/init.sh:/app/docker/init.sh:ro
- ./docker/superset/images/arcadia_logo.png:/app/superset/static/assets/images/arcadia_logo.png:ro
- ./docker/superset/images/arcadia_logo_dark.png:/app/superset/static/assets/images/arcadia_logo_dark.png:ro
- ./docker/superset/images/superset-logo-horiz.png:/app/superset/static/assets/images/superset-logo-horiz.png:ro
- ./docker/superset/images/favicon.png:/app/superset/static/assets/images/favicon.png:ro
- ./docker/superset/images/arcadia_favicon.png:/app/superset/static/assets/images/arcadia_favicon.png:ro
- superset_home:/app/superset_home
depends_on:
db:
@ -389,6 +416,24 @@ services:
- "traefik.http.middlewares.superset-https-redirect.redirectscheme.permanent=true"
profiles: [bi]
# ── Neo4j (Knowledge Graph) ─────────────────────────────────────────────────
# Ativar com: docker compose --profile kg up
neo4j:
image: neo4j:5
restart: always
environment:
NEO4J_AUTH: neo4j/${NEO4J_PASSWORD:-arcadia123}
NEO4J_PLUGINS: '["apoc"]'
volumes:
- neo4j_data:/data
- neo4j_logs:/logs
ports:
- "7474:7474"
- "7687:7687"
networks:
- arcadia-internal
profiles: [kg]
networks:
arcadia-internal:
driver: bridge
@ -403,9 +448,8 @@ volumes:
redis_data:
ollama_models:
open_webui_data:
neo4j_data:
neo4j_logs:
plus_db:
plus_storage:
superset_home:

View File

@ -63,7 +63,25 @@ services:
condition: service_healthy
profiles: [bi]
# ── Neo4j (Knowledge Graph) ─────────────────────────────────────────────────
# Ativar com: docker compose --profile kg up
neo4j:
image: neo4j:5
restart: unless-stopped
environment:
NEO4J_AUTH: neo4j/${NEO4J_PASSWORD:-arcadia123}
NEO4J_PLUGINS: '["apoc"]'
volumes:
- neo4j_data:/data
- neo4j_logs:/logs
ports:
- "7474:7474"
- "7687:7687"
profiles: [kg]
volumes:
pgdata:
uploads:
superset_home:
neo4j_data:
neo4j_logs:

View File

@ -38,8 +38,8 @@ model_list:
- model_name: arcadia-agent
litellm_params:
model: ollama/arcadia-agent
api_base: os.environ/OLLAMA_BASE_URL
model: groq/llama-3.3-70b-versatile
api_key: os.environ/GROQ_API_KEY
- model_name: nomic-embed-text
litellm_params:
@ -70,10 +70,10 @@ model_list:
# api_key: os.environ/ANTHROPIC_API_KEY
# ── TIER 3: Groq (opt-in — inferência rápida sem dados persistidos) ──────────
# - model_name: groq-llama
# litellm_params:
# model: groq/llama-3.3-70b-versatile
# api_key: os.environ/GROQ_API_KEY
- model_name: groq-llama
litellm_params:
model: groq/llama-3.3-70b-versatile
api_key: os.environ/GROQ_API_KEY
# ── Modelo padrão do Arcádia (Manus usa este) ─────────────────────────────────
# Prioridade: DeepSeek R1 14B (TIER 2) → llama3.2 (fallback rápido)
@ -87,9 +87,6 @@ router_settings:
fallbacks:
- {"gpt-4o": ["arcadia-agent"]}
- {"gpt-4o-mini": ["arcadia-agent"]}
- {"arcadia-default": ["llama3.2"]}
- {"arcadia-agent": ["llama3.2"]}
- {"arcadia-finetuned": ["deepseek-r1"]}
- {"arcadia-embed": ["nomic-embed-text"]}
litellm_settings:

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

View File

@ -46,6 +46,8 @@ SQLLAB_ASYNC_TIME_LIMIT_SEC = 300
# ── Branding Arcádia ──────────────────────────────────────────────────────────
APP_NAME = "Arcádia Insights"
APP_ICON = "/static/assets/images/arcadia_logo.png"
APP_ICON_WIDTH = 150
LOGO_TARGET_PATH = "/"
FAVICONS = [{"href": "/static/assets/images/favicon.png"}]

View File

@ -0,0 +1,104 @@
-- Migration 0003: Arcádia Agentic Suite — Skills POO
-- Criado em: 2026-03-24 | Autor: João
-- Adiciona tabelas arcadia_skills e skill_executions
-- NÃO altera nem remove xos_skill_registry (modelo XOS legado preservado)
-- ─── Tabela principal de Skills (modelo orientado a objetos) ─────────────────
CREATE TABLE IF NOT EXISTS "arcadia_skills" (
"id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Identidade
"name" VARCHAR(255) NOT NULL,
"slug" VARCHAR(255) NOT NULL,
"description" TEXT,
"version" VARCHAR(50) NOT NULL DEFAULT '1.0.0',
"icon" VARCHAR(100),
"tags" TEXT[],
-- Namespace multi-tenant (system > tenant > company > user)
"namespace" VARCHAR(20) NOT NULL DEFAULT 'tenant',
"tenant_id" INTEGER REFERENCES "tenants"("id") ON DELETE CASCADE,
"company_id" INTEGER,
"user_id" VARCHAR REFERENCES "users"("id") ON DELETE SET NULL,
-- Herança POO: lista de slugs referenciando outras skills
"extends" TEXT[],
-- Interfaces / contratos implementados
"implements" TEXT[],
-- Encapsulamento
"visibility_execute" VARCHAR(20) DEFAULT 'public',
"visibility_params" VARCHAR(20) DEFAULT 'public',
-- Composição: dependências como referências /tipo/caminho
"dependencies" TEXT[],
-- Trigger automático
"trigger_type" VARCHAR(30),
"trigger_config" JSONB,
-- Corpo da skill (Markdown com blocos /skill/, /kg/, /tool/, etc.)
"body" TEXT,
-- Schemas de entrada e saída
"parameters_schema" JSONB,
"return_schema" JSONB,
-- Estado
"status" VARCHAR(20) NOT NULL DEFAULT 'draft',
"is_system" BOOLEAN DEFAULT false,
-- Autoria
"author" VARCHAR(255),
"created_by" VARCHAR REFERENCES "users"("id") ON DELETE SET NULL,
"created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Slug único por namespace + tenant
CREATE UNIQUE INDEX IF NOT EXISTS idx_arcadia_skills_slug_tenant
ON arcadia_skills(slug, tenant_id, namespace);
-- Índices de consulta frequente
CREATE INDEX IF NOT EXISTS idx_arcadia_skills_tenant ON arcadia_skills(tenant_id);
CREATE INDEX IF NOT EXISTS idx_arcadia_skills_namespace ON arcadia_skills(namespace);
CREATE INDEX IF NOT EXISTS idx_arcadia_skills_status ON arcadia_skills(status);
CREATE INDEX IF NOT EXISTS idx_arcadia_skills_created_by ON arcadia_skills(created_by);
-- ─── Tabela de execuções de skills ──────────────────────────────────────────
CREATE TABLE IF NOT EXISTS "skill_executions" (
"id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"skill_id" UUID NOT NULL REFERENCES "arcadia_skills"("id") ON DELETE CASCADE,
-- Contexto de execução
"tenant_id" INTEGER REFERENCES "tenants"("id"),
"company_id" INTEGER,
"user_id" VARCHAR REFERENCES "users"("id") ON DELETE SET NULL,
-- Origem
"triggered_by" VARCHAR(30),
"automation_id" INTEGER,
"parent_execution_id" UUID,
-- Dados
"input_params" JSONB,
"output_result" JSONB,
"resolved_dependencies" JSONB,
-- Estado
"status" VARCHAR(20) NOT NULL DEFAULT 'pending',
"error_message" TEXT,
"duration_ms" INTEGER,
-- Imutabilidade / auditoria
"audit_hash" VARCHAR(64),
"started_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"completed_at" TIMESTAMP
);
-- Índices de consulta frequente
CREATE INDEX IF NOT EXISTS idx_skill_executions_skill_id ON skill_executions(skill_id);
CREATE INDEX IF NOT EXISTS idx_skill_executions_tenant ON skill_executions(tenant_id);
CREATE INDEX IF NOT EXISTS idx_skill_executions_status ON skill_executions(status);
CREATE INDEX IF NOT EXISTS idx_skill_executions_started ON skill_executions(started_at DESC);

View File

@ -0,0 +1,36 @@
-- Migration: Add skill_versions table for Git-like versioning
-- Phase 2.4: Versionamento Git-like de skills
CREATE TABLE IF NOT EXISTS skill_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
skill_id UUID NOT NULL REFERENCES arcadia_skills(id) ON DELETE CASCADE,
sha VARCHAR(64) NOT NULL,
message TEXT NOT NULL,
author VARCHAR(255) NOT NULL,
content JSONB NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
-- Índices
UNIQUE(skill_id, sha),
INDEX skill_versions_skill_id (skill_id),
INDEX skill_versions_created_at (created_at)
);
-- Trigger para criar versão inicial ao criar skill (opcional)
-- CREATE OR REPLACE FUNCTION create_initial_skill_version()
-- RETURNS TRIGGER AS $$
-- BEGIN
-- INSERT INTO skill_versions (skill_id, sha, message, author, content)
-- VALUES (
-- NEW.id,
-- encode(digest(NEW.id || NEW.content::text, 'sha256'), 'hex'),
-- 'Initial version',
-- 'system',
-- NEW.content
-- );
-- RETURN NEW;
-- END;
-- $$ LANGUAGE plpgsql;
--
-- CREATE TRIGGER skill_initial_version AFTER INSERT ON arcadia_skills
-- FOR EACH ROW EXECUTE FUNCTION create_initial_skill_version();

View File

@ -800,7 +800,7 @@ ${JSON.stringify(sampleData.slice(0, 10), null, 2)}
Pergunta do usuário: ${question}`;
const response = await openai.chat.completions.create({
model: "gpt-4o-mini",
model: "arcadia-agent",
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt }

View File

@ -38,7 +38,7 @@ export interface AgentThought {
export class ManusIntelligence {
private static instance: ManusIntelligence;
private model = "gpt-4o";
private model = "arcadia-agent";
private callCount = 0;
private tokenCount = 0;
private errorCount = 0;

View File

@ -31,7 +31,7 @@ function requireAuth(req: Request, res: Response, next: NextFunction) {
// Apply auth to all routes
router.use(requireAuth);
// Helper to get tenant ID from request header or user's first tenant
// Helper to get tenant ID from request header or user's first tenant (auto-creates if none)
async function getTenantId(req: Request): Promise<number | null> {
const userId = (req.user as any).id;
const headerTenantId = req.headers["x-tenant-id"];
@ -40,8 +40,13 @@ async function getTenantId(req: Request): Promise<number | null> {
const isMember = await compassStorage.isUserInTenant(userId, tenantId);
return isMember ? tenantId : null;
}
const tenants = await compassStorage.getUserTenants(userId);
return tenants.length > 0 ? tenants[0].id : null;
const userTenants = await compassStorage.getUserTenants(userId);
if (userTenants.length > 0) return userTenants[0].id;
// Auto-cria tenant padrão para o usuário na primeira vez
const userName = (req.user as any).name || (req.user as any).username || "Minha Organização";
const tenant = await compassStorage.createTenant({ name: userName, plan: "free", status: "active" });
await compassStorage.addUserToTenant({ tenantId: tenant.id, userId, role: "owner", isOwner: "true" });
return tenant.id;
}
// Validate tenant membership

View File

@ -3,6 +3,8 @@ import { manusService } from "./manus/service";
import { db } from "../db/index";
import { sql } from "drizzle-orm";
const router = Router();
const DEV_AGENT_CONTEXT = `CONTEXTO ESPECIAL - DEV AGENT:
@ -77,9 +79,7 @@ Usuário: ${message}
Responda como Dev Agent, criando os recursos solicitados:`;
const result = await manusService.run(userId, fullPrompt, []);
const responseContent = result.finalResponse || "Desculpe, não consegui processar sua solicitação.";
const responseContent = await manusService.runSync(userId, fullPrompt, []);
const jsonMatch = responseContent.match(/```json\s*([\s\S]*?)\s*```/);
let action = null;

View File

@ -174,7 +174,7 @@ Responda APENAS com o código, sem explicações adicionais.`;
: prompt;
const response = await openai.chat.completions.create({
model: "gpt-4o",
model: "arcadia-agent",
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt },
@ -228,7 +228,7 @@ Use português brasileiro.`;
: message;
const response = await openai.chat.completions.create({
model: "gpt-4o",
model: "arcadia-agent",
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt },

View File

@ -1,5 +1,8 @@
import type { Express, Request, Response } from "express";
import { manusService } from "./service";
import { db } from "../../db/index";
import { manusRuns, manusSteps } from "@shared/schema";
import { eq, and } from "drizzle-orm";
export function registerManusRoutes(app: Express): void {
const handleManusRun = async (req: Request, res: Response) => {
@ -53,7 +56,7 @@ export function registerManusRoutes(app: Express): void {
if (!req.isAuthenticated()) {
return res.status(401).json({ error: "Not authenticated" });
}
const runs = await manusService.getUserRuns(req.user!.id);
res.json(runs);
} catch (error) {
@ -61,4 +64,40 @@ export function registerManusRoutes(app: Express): void {
res.status(500).json({ error: "Failed to get runs" });
}
});
app.delete("/api/manus/runs/:id", async (req: Request, res: Response) => {
try {
if (!req.isAuthenticated()) {
return res.status(401).json({ error: "Not authenticated" });
}
const runId = parseInt(req.params.id);
manusService.cancelRun(runId); // sinaliza o loop para parar
await db.delete(manusSteps).where(eq(manusSteps.runId, runId));
await db.delete(manusRuns).where(and(eq(manusRuns.id, runId), eq(manusRuns.userId, req.user!.id)));
res.json({ success: true });
} catch (error) {
console.error("Manus delete run error:", error);
res.status(500).json({ error: "Failed to delete run" });
}
});
app.delete("/api/manus/runs", async (req: Request, res: Response) => {
try {
if (!req.isAuthenticated()) {
return res.status(401).json({ error: "Not authenticated" });
}
const userRuns = await manusService.getUserRuns(req.user!.id);
const runIds = userRuns.map((r: any) => r.id);
if (runIds.length > 0) {
for (const id of runIds) {
await db.delete(manusSteps).where(eq(manusSteps.runId, id));
}
await db.delete(manusRuns).where(eq(manusRuns.userId, req.user!.id));
}
res.json({ success: true, deleted: runIds.length });
} catch (error) {
console.error("Manus delete all runs error:", error);
res.status(500).json({ error: "Failed to delete runs" });
}
});
}

View File

@ -14,26 +14,73 @@ const openai = new OpenAI({
baseURL: process.env.AI_INTEGRATIONS_OPENAI_BASE_URL,
});
const SYSTEM_PROMPT = `Você é o Manus, assistente da Arcádia Suite.
const SYSTEM_PROMPT = `Você é o Agente Arcádia Manus, assistente empresarial inteligente da Arcádia Suite.
REGRAS CRÍTICAS:
1. Responda APENAS o que foi perguntado. Seja direto e objetivo.
2. NUNCA adicione SWOT, PDCA, Canvas, matrizes ou análises não pedidas.
3. Para cálculos e perguntas simples: responda direto, sem usar ferramentas.
4. Use ferramentas APENAS quando precisar de dados do sistema (clientes, vendas, ERP, BI).
5. Responda SEMPRE em JSON no formato abaixo.
IDENTIDADE:
- Você é o Manus, IA da Arcádia Suite. Se perguntado: "Sou o Manus, agente IA da Arcádia Suite."
- Não revele detalhes técnicos da infraestrutura (modelos, APIs, servidores).
FERRAMENTAS (use se necessário):
REGRAS DE COMPORTAMENTO:
- Seja PROATIVO: execute análises sem pedir confirmação desnecessária
- Para perguntas simples e cálculos: responda DIRETO, sem usar ferramentas
- NUNCA adicione SWOT, Canvas, PDCA, frameworks ou análises NÃO solicitados
- Para AÇÕES DESTRUTIVAS (deletar, modificar dados): informe o que será feito antes de executar
- Máximo de 10 passos por execução
- Complete SEMPRE a tarefa e apresente o resultado final
ANÁLISE DE DADOS (quando usar ferramentas de dados):
- Apresente dados em TABELAS MARKDOWN formatadas (| Coluna | Valor |)
- Calcule variações percentuais, identifique tendências
- Forneça insights e interpretações, não números brutos
- Ao analisar documentos: extraia dados E forneça interpretação completa
CRIAÇÃO DE GRÁFICOS:
- Use generate_chart para gráficos visuais (NÃO use python_execute para isso)
- Tipos: bar (barras), line (linha), pie (pizza), area (área)
- Dados: JSON array [{"name":"2023","valor":100}]
- Use múltiplas séries quando fizer sentido
PESQUISA E CONHECIMENTO:
- Para PESQUISA PROFUNDA: use deep_research (busca e sintetiza múltiplas fontes)
- Para APRENDER uma URL: use learn_url
- Para BUSCAR conhecimento interno: use semantic_search PRIMEIRO, depois deep_research se necessário
- Para NAVEGAR em página: use web_browse
- NUNCA diga "não consigo acessar" sempre tente as ferramentas
MÓDULO BI (ARCÁDIA INSIGHTS):
- bi_stats: estatísticas gerais do BI
- bi_list_tables: tabelas disponíveis
- bi_get_table_columns: colunas de uma tabela
- bi_create_dataset: criar consultas SQL
- bi_execute_query: executar dataset e obter dados
- bi_create_chart: criar gráficos persistentes
- bi_create_dashboard: organizar gráficos em painéis
COMUNICAÇÃO ENTRE AGENTES (A2A):
- list_agents: ver agentes disponíveis
- call_agent: delegar tarefas a agentes especializados
- Para tarefas especializadas (fiscal, jurídico, vendas): verifique agentes registrados
FERRAMENTAS DISPONÍVEIS:
${getToolsDescription()}
FORMATO OBRIGATÓRIO:
{"thought": "raciocínio breve", "tool": "finish", "tool_input": {"answer": "resposta direta"}}
FORMATO DE RESPOSTA OBRIGATÓRIO (JSON):
{"thought": "raciocínio sobre o próximo passo", "tool": "nome_ferramenta", "tool_input": {"param": "valor"}}
Com ferramenta:
{"thought": "raciocínio", "tool": "nome_ferramenta", "tool_input": {"param": "valor"}}`;
Para concluir:
{"thought": "raciocínio final", "tool": "finish", "tool_input": {"answer": "resposta COMPLETA com dados, tabelas, análise e conclusão"}}
REGRA CRÍTICA: O campo "answer" deve conter TODO o conteúdo tabelas, cálculos, insights. NUNCA apenas "relatório gerado".`;
class ManusService extends EventEmitter {
private async executeTool(tool: string, input: Record<string, any>, userId: string): Promise<ToolResult> {
private cancelledRuns = new Set<number>();
cancelRun(runId: number) {
this.cancelledRuns.add(runId);
setTimeout(() => this.cancelledRuns.delete(runId), 60000); // limpa após 1 min
}
async executeTool(tool: string, input: Record<string, any>, userId: string): Promise<ToolResult> {
try {
switch (tool) {
case "web_search":
@ -2137,6 +2184,19 @@ class ManusService extends EventEmitter {
return { runId: run.id };
}
async runSync(userId: string, prompt: string, attachedFiles?: Array<{name: string, content: string, base64?: string}>): Promise<string> {
const [run] = await db.insert(manusRuns).values({
userId,
prompt,
status: "running"
}).returning();
await this.executeAgentLoop(run.id, userId, prompt, attachedFiles);
const [completed] = await db.select().from(manusRuns).where(eq(manusRuns.id, run.id));
return completed?.result || "Não foi possível processar a solicitação.";
}
private async executeAgentLoop(runId: number, userId: string, prompt: string, attachedFiles?: Array<{name: string, content: string, base64?: string}>, conversationHistory?: Array<{role: string; content: string}>) {
let userPrompt = prompt;
if (attachedFiles && attachedFiles.length > 0) {
@ -2169,14 +2229,26 @@ class ManusService extends EventEmitter {
while (step < maxSteps && !finished) {
step++;
// Verificar cancelamento antes de cada step
if (this.cancelledRuns.has(runId)) {
this.cancelledRuns.delete(runId);
await db.update(manusRuns).set({ status: "stopped", completedAt: new Date() }).where(eq(manusRuns.id, runId));
return;
}
try {
const response = await openai.chat.completions.create({
const STEP_TIMEOUT_MS = 60000; // 60s por step
const responsePromise = openai.chat.completions.create({
model: "arcadia-agent",
messages,
temperature: 0.2,
max_tokens: 1500,
});
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("Step timeout (60s)")), STEP_TIMEOUT_MS)
);
const response = await Promise.race([responsePromise, timeoutPromise]);
const content = response.choices[0]?.message?.content || "";
messages.push({ role: "assistant", content });

View File

@ -90,6 +90,8 @@ router.post('/upload', upload.single('file'), async (req, res) => {
if (fileName.endsWith('.zip')) sourceType = 'mongodb';
else if (fileName.endsWith('.json')) sourceType = 'json';
else if (fileName.endsWith('.csv')) sourceType = 'csv';
else if (fileName.endsWith('.rar')) sourceType = 'sql-rar';
else if (fileName.endsWith('.sql.gz') || fileName.endsWith('.sql')) sourceType = 'sql';
const [job] = await db.insert(migrationJobs).values({
name: name || `Migração ${new Date().toLocaleDateString('pt-BR')}`,
@ -108,26 +110,26 @@ router.post('/upload', upload.single('file'), async (req, res) => {
await execAsync(`unzip -o "${filePath}" -d "${extractDir}"`);
const subdirs = fs.readdirSync(extractDir).filter(f =>
fs.statSync(path.join(extractDir, f)).isDirectory() &&
const subdirs = fs.readdirSync(extractDir).filter(f =>
fs.statSync(path.join(extractDir, f)).isDirectory() &&
fs.readdirSync(path.join(extractDir, f)).some(sf => sf.endsWith('.bson'))
);
const bsonDir = subdirs.length > 0
const bsonDir = subdirs.length > 0
? path.join(extractDir, subdirs[0])
: extractDir;
await db.update(migrationJobs)
.set({
.set({
status: 'analyzing',
importConfig: { extractPath: bsonDir }
})
.where(eq(migrationJobs.id, job.id));
const analysis = analyzeBackupDirectory(bsonDir);
await db.update(migrationJobs)
.set({
.set({
status: 'mapping',
totalRecords: analysis.totalRecords,
analysisResult: analysis
@ -148,6 +150,87 @@ router.post('/upload', upload.single('file'), async (req, res) => {
});
}
}
} else if (sourceType === 'sql-rar') {
const extractDir = path.join(UPLOAD_DIR, `job-${job.id}`);
fs.mkdirSync(extractDir, { recursive: true });
await db.update(migrationJobs)
.set({ status: 'analyzing' })
.where(eq(migrationJobs.id, job.id));
await execAsync(`7z x "${filePath}" -o"${extractDir}" -y`);
const allFiles = fs.readdirSync(extractDir);
const sqlGzFile = allFiles.find(f => f.endsWith('.sql.gz'));
const sqlFile = allFiles.find(f => f.endsWith('.sql'));
let finalSqlPath: string;
if (sqlGzFile) {
const gzPath = path.join(extractDir, sqlGzFile);
finalSqlPath = gzPath.replace(/\.gz$/, '');
await execAsync(`gunzip -f "${gzPath}"`);
} else if (sqlFile) {
finalSqlPath = path.join(extractDir, sqlFile);
} else {
throw new Error('Nenhum arquivo .sql ou .sql.gz encontrado dentro do RAR');
}
const sqlContent = fs.readFileSync(finalSqlPath, 'utf8');
const tableMatches = sqlContent.match(/CREATE TABLE\s+`?(\w+)`?/gi) || [];
const tables = tableMatches.map(m => m.replace(/CREATE TABLE\s+`?/i, '').replace(/`/g, '').trim());
const lineCount = sqlContent.split('\n').length;
await db.update(migrationJobs)
.set({
status: 'mapping',
totalRecords: lineCount,
analysisResult: {
type: 'sql',
sqlPath: finalSqlPath,
tables,
tableCount: tables.length,
lineCount,
fileSizeBytes: fs.statSync(finalSqlPath).size
},
importConfig: { sqlPath: finalSqlPath }
})
.where(eq(migrationJobs.id, job.id));
} else if (sourceType === 'sql') {
const extractDir = path.join(UPLOAD_DIR, `job-${job.id}`);
fs.mkdirSync(extractDir, { recursive: true });
await db.update(migrationJobs)
.set({ status: 'analyzing' })
.where(eq(migrationJobs.id, job.id));
let finalSqlPath: string;
if (fileName.endsWith('.sql.gz')) {
finalSqlPath = path.join(extractDir, fileName.replace(/\.gz$/, ''));
await execAsync(`gunzip -c "${filePath}" > "${finalSqlPath}"`);
} else {
finalSqlPath = filePath;
}
const sqlContent = fs.readFileSync(finalSqlPath, 'utf8');
const tableMatches = sqlContent.match(/CREATE TABLE\s+`?(\w+)`?/gi) || [];
const tables = tableMatches.map(m => m.replace(/CREATE TABLE\s+`?/i, '').replace(/`/g, '').trim());
const lineCount = sqlContent.split('\n').length;
await db.update(migrationJobs)
.set({
status: 'mapping',
totalRecords: lineCount,
analysisResult: {
type: 'sql',
sqlPath: finalSqlPath,
tables,
tableCount: tables.length,
lineCount,
fileSizeBytes: fs.statSync(finalSqlPath).size
},
importConfig: { sqlPath: finalSqlPath }
})
.where(eq(migrationJobs.id, job.id));
}
const [updatedJob] = await db.select().from(migrationJobs).where(eq(migrationJobs.id, job.id));

View File

@ -0,0 +1,94 @@
import type { Express, Request, Response } from "express";
import crypto from "crypto";
import { createNode } from "../graph/service";
const MIROFLOW_HOST = process.env.MIROFLOW_HOST || "localhost";
const MIROFLOW_PORT = parseInt(process.env.MIROFLOW_PORT || "8006", 10);
const MIROFLOW_URL = `http://${MIROFLOW_HOST}:${MIROFLOW_PORT}`;
const MIROFLOW_TIMEOUT = 300_000; // 5 minutos — deepseek-r1:14b pode levar 60-120s
const MIROFLOW_HEALTH_TIMEOUT = 5_000; // 5 segundos para health check
async function proxyToMiroFlow(path: string, method: string = "GET", body?: object): Promise<any> {
const timeoutMs = path === "/health" ? MIROFLOW_HEALTH_TIMEOUT : MIROFLOW_TIMEOUT;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(`${MIROFLOW_URL}${path}`, {
method,
headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) {
const err = await response.json().catch(() => ({ detail: response.statusText }));
throw new Error(err.detail || `MiroFlow error: ${response.status}`);
}
return await response.json();
} catch (err: any) {
clearTimeout(timeout);
if (err.name === "AbortError") throw new Error("MiroFlow timeout (300s)");
throw err;
}
}
async function registerExecutionInKG(req: Request, execData: any, inputBody: any): Promise<void> {
try {
const auditHash = crypto
.createHash("sha256")
.update(JSON.stringify({
execution_id: execData.execution_id,
agent: execData.agent,
model: execData.model,
input: inputBody,
output: execData.result,
}))
.digest("hex");
await createNode({
type: "miroflow_execution",
tenantId: (req.user as any)?.tenantId ?? null,
data: {
...execData,
input: inputBody,
auditHash,
immutable: true,
registeredAt: new Date().toISOString(),
},
});
} catch (kgErr: any) {
// KG failure não deve bloquear a resposta ao cliente
console.error("[MiroFlow] Falha ao registrar no KG:", kgErr.message);
}
}
export function registerMiroFlowRoutes(app: Express): void {
app.get("/api/miroflow/health", async (_req: Request, res: Response) => {
try {
const data = await proxyToMiroFlow("/health", "GET");
res.json({ online: true, url: MIROFLOW_URL, ...data });
} catch {
res.json({ online: false, url: MIROFLOW_URL });
}
});
app.post("/api/miroflow/analyze", async (req: Request, res: Response) => {
if (!req.isAuthenticated()) {
return res.status(401).json({ error: "Não autenticado" });
}
try {
const inputBody = {
...req.body,
tenant_id: (req.user as any)?.tenantId ?? null,
};
const data = await proxyToMiroFlow("/analyze", "POST", inputBody);
// Registrar no KG de forma assíncrona (não bloqueia a resposta)
registerExecutionInKG(req, data, req.body).catch(() => {});
res.json(data);
} catch (err: any) {
res.status(502).json({ error: err.message });
}
});
console.log(`[MiroFlow Proxy] Rotas registradas -> ${MIROFLOW_URL}`);
}

View File

@ -0,0 +1 @@
export { registerMiroFlowRoutes } from "./engine-proxy";

@ -0,0 +1 @@
Subproject commit 9d180f1eeb5e301c440ad7ce8b12c14222779040

@ -0,0 +1 @@
Subproject commit bd7ec41b51dd89f4c5551e3d27917b41af4a3793

View File

@ -0,0 +1,4 @@
import sys
import os
# Add server/python directory to path so test modules can import service files
sys.path.insert(0, os.path.dirname(__file__))

View File

@ -0,0 +1,133 @@
"""
Arcádia MiroFlow Service Agentes científicos via MiroFlow + Ollama local
FastAPI microserviço porta 8006
"""
import os
import sys
import time
import uuid
from typing import Optional
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
# Adicionar o submodule MiroFlow ao path
MIROFLOW_MODULE_PATH = os.path.join(
os.path.dirname(__file__), "..", "modules", "miroflow"
)
sys.path.insert(0, os.path.abspath(MIROFLOW_MODULE_PATH))
from miroflow.agents.factory import build_agent
from miroflow.agents.context import AgentContext
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
RESEARCHER_MODEL = os.getenv("MIROFLOW_RESEARCHER_MODEL", "llama3.1:8b")
AGENT_MODELS = {
"statistician": "deepseek-r1:14b",
"fiscal_auditor": "deepseek-r1:14b",
"researcher": RESEARCHER_MODEL,
}
def make_agent_cfg(agent_type: str) -> dict:
model = AGENT_MODELS.get(agent_type, "deepseek-r1:14b")
return {
"type": "IterativeAgentWithToolAndRollback",
"name": f"arcadia_{agent_type}",
"max_turns": 10,
"llm": {
"provider_class": "UnifiedOpenAIClient",
"model_name": model,
"api_key": "ollama",
"base_url": f"{OLLAMA_BASE_URL}/v1",
"max_tokens": 8192,
"temperature": 0.7,
"top_p": 1.0,
"min_p": 0.0,
"top_k": -1,
"reasoning_effort": None,
"repetition_penalty": 1.0,
"max_context_length": -1,
"async_client": True,
"disable_cache_control": True,
"keep_tool_result": -1,
"use_tool_calls": False,
"oai_tool_thinking": False,
},
}
async def run_agent(agent_type: str, task: str) -> tuple[str, str]:
"""Retorna (result_text, model_name)"""
if agent_type not in AGENT_MODELS:
raise ValueError(f"Agente desconhecido: {agent_type}. Use: {list(AGENT_MODELS.keys())}")
cfg = make_agent_cfg(agent_type)
agent = build_agent(cfg)
ctx = AgentContext(
task_description=task,
system_prompt=(
"Você é um agente científico da Arcádia Suite especializado em análise de dados "
"empresariais. Responda em português de forma clara e objetiva. "
"Quando precisar de dados, explique o que analisaria e quais conclusões seriam esperadas."
),
initial_user_message=task,
)
result = await agent.run(ctx)
text = result.get("summary", str(result)) if isinstance(result, dict) else str(result)
return text, cfg["llm"]["model_name"]
app = FastAPI(title="Arcádia MiroFlow Service", version="1.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
class AnalyzeRequest(BaseModel):
agent: str
task: str
context: dict = {}
tenant_id: Optional[int] = None
class AnalyzeResponse(BaseModel):
agent: str
model: str
result: str
execution_id: str
duration_ms: int
@app.get("/health")
async def health_check():
return {"status": "ok", "service": "miroflow", "agents": list(AGENT_MODELS.keys())}
@app.post("/analyze", response_model=AnalyzeResponse)
async def analyze(req: AnalyzeRequest):
if req.agent not in AGENT_MODELS:
raise HTTPException(status_code=422, detail=f"Agente inválido: {req.agent}")
start = time.time()
try:
result_text, model_name = await run_agent(req.agent, req.task)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
return AnalyzeResponse(
agent=req.agent,
model=model_name,
result=result_text,
execution_id=str(uuid.uuid4()),
duration_ms=int((time.time() - start) * 1000),
)
if __name__ == "__main__":
import uvicorn
port = int(os.environ.get("MIROFLOW_PORT", 8006))
uvicorn.run(app, host="0.0.0.0", port=port)

View File

@ -0,0 +1,53 @@
"""Tests for miroflow_service.py -- REQ-3.1 to REQ-3.4"""
import sys
import os
MIROFLOW_MODULE_PATH = os.path.join(
os.path.dirname(__file__), "../modules/miroflow"
)
sys.path.insert(0, os.path.abspath(MIROFLOW_MODULE_PATH))
import pytest
from httpx import AsyncClient, ASGITransport
from miroflow_service import app, make_agent_cfg, AnalyzeRequest, AnalyzeResponse
@pytest.mark.asyncio
async def test_health():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
resp = await c.get("/health")
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "ok"
assert data["service"] == "miroflow"
def test_statistician_config():
cfg = make_agent_cfg("statistician")
llm = cfg["llm"]
assert llm["model_name"] == "deepseek-r1:14b"
assert llm["base_url"].endswith("/v1")
def test_fiscal_auditor_config():
cfg = make_agent_cfg("fiscal_auditor")
llm = cfg["llm"]
assert llm["model_name"] == "deepseek-r1:14b"
def test_researcher_config():
cfg = make_agent_cfg("researcher")
llm = cfg["llm"]
assert llm["model_name"] in ("llama3.1:8b", "llama3.2:3b")
@pytest.mark.asyncio
async def test_analyze_request_validation():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
resp = await c.post("/analyze", json={"agent": "invalid", "task": "test"})
assert resp.status_code == 422
def test_analyze_request_schema():
req = AnalyzeRequest(agent="statistician", task="Analyze this", context={"k":"v"}, tenant_id=1)
assert req.agent == "statistician"
assert req.task == "Analyze this"
assert req.tenant_id == 1
resp = AnalyzeResponse(agent="statistician", model="deepseek-r1:14b", result="r", execution_id="abc", duration_ms=500)
assert resp.agent == "statistician"
assert resp.model == "deepseek-r1:14b"
assert resp.duration_ms == 500

View File

@ -1,16 +1,34 @@
export const ARCADIA_AGENT_SYSTEM_PROMPT = `Você é o Manus, assistente da Arcádia Suite — plataforma empresarial soberana da OnboardBI.
import { getToolsDescription } from "../../manus/tools";
export const ARCADIA_AGENT_SYSTEM_PROMPT = `Você é o Manus, assistente empresarial inteligente da Arcádia Suite.
IDENTIDADE:
- Roda localmente com LLM open-source. Se perguntado: "Sou o Manus, IA local da Arcádia Suite."
- NÃO é OpenAI, ChatGPT ou nenhum serviço externo.
- Você é o Manus, assistente empresarial da Arcádia Suite.
- Se perguntado sobre identidade: "Sou o Manus, assistente da Arcádia Suite."
- NÃO mencione modelos de linguagem, APIs ou infraestrutura técnica.
- NUNCA diga que é uma "IA local" ou que "não tem acesso externo" você tem capacidades completas.
REGRAS:
- Responda APENAS o que foi perguntado. Seja direto e objetivo.
- Para cálculos, perguntas simples e saudações: responda diretamente.
- NUNCA adicione SWOT, Canvas, PDCA, matrizes ou frameworks não solicitados.
- NUNCA adicione rodapés, citações de fonte ou "Inteligência Arcádia Business" automaticamente.
COMPORTAMENTO:
- Seja direto e objetivo. Responda exatamente o que foi perguntado.
- Para cálculos e perguntas simples: responda diretamente, sem rodeios.
- NUNCA adicione SWOT, Canvas, PDCA, matrizes ou frameworks NÃO solicitados.
- NUNCA adicione rodapés automáticos ou citações de fonte não pedidas.
- Quando não souber algo, diga claramente sem inventar.
- Use Markdown para dados tabulares quando fizer sentido.`;
- Use Markdown para tabelas e dados estruturados quando fizer sentido.
CAPACIDADES COMPLETAS DO MANUS:
- Responde perguntas gerais, realiza cálculos e análises
- Pesquisa na web e sintetiza informações atualizadas
- Analisa documentos, planilhas e arquivos anexados
- Consulta dados do ERP, CRM, financeiro e base de conhecimento
- Gera gráficos, relatórios e dashboards no BI
- Executa diagnósticos empresariais (Canvas, SWOT, PDCA) quando SOLICITADO
- Comunica-se com agentes especializados da plataforma
ANÁLISE E DADOS:
- Use tabelas Markdown formatadas para dados estruturados.
- Calcule variações percentuais e tendências quando relevante.
- Forneça insights reais, não apenas dados brutos.`;
export interface DiagnosticContext {
canvas?: any[];
@ -116,3 +134,79 @@ ${diagnosticContext.requirements.map(req =>
return prompt;
}
export function buildAgentPromptForChat(
knowledgeBaseContext: string,
fileContent?: string,
diagnosticContext?: DiagnosticContext
): string {
const now = new Date().toLocaleString('pt-BR', { timeZone: 'America/Sao_Paulo', dateStyle: 'full', timeStyle: 'short' });
let prompt = `Você é o Manus, assistente da Arcádia Suite.
IDENTIDADE: Manus, assistente da Arcádia Suite.
- NUNCA mencione "OnboardBI", modelos de linguagem, APIs ou infraestrutura.
- NUNCA diga que não tem acesso a dados use as ferramentas para buscar.
- Se perguntado sobre identidade: "Sou o Manus, assistente da Arcádia Suite."
DATA/HORA: ${now}
FERRAMENTAS DISPONÍVEIS:
${getToolsDescription()}
REGRA ABSOLUTA: Responda SEMPRE e APENAS em JSON válido. NUNCA escreva texto livre.
FORMATO (toda resposta deve ser exatamente assim):
{"thought": "raciocínio breve", "tool": "nome_ferramenta", "tool_input": {"param": "valor"}}
Para perguntas simples/cálculos (sem precisar de dados do sistema):
{"thought": "resposta direta", "tool": "finish", "tool_input": {"answer": "resposta em Markdown"}}
QUANDO USAR FERRAMENTAS:
- Perguntas sobre dados da empresa, clientes, vendas, financeiro erp_query
- Perguntas sobre BI, tabelas, dashboards bi_list_tables ou bi_execute_query
- Pesquisa de mercado, notícias, tendências deep_research ou web_search
- Base de conhecimento interna knowledge_query
- Análise de documento analyze_file
- Qualquer dúvida sobre o que existe no sistema use as ferramentas para descobrir
REGRAS:
- NUNCA diga "não tenho acesso" use as ferramentas.
- NUNCA adicione SWOT, Canvas, PDCA sem ser solicitado.
- Para análises com dados: use tabelas Markdown e variações percentuais.
- Máximo 8 passos.`;
if (knowledgeBaseContext) {
prompt += `\n\n## Base de Conhecimento\nDocumentos relevantes encontrados:\n\n${knowledgeBaseContext}`;
}
if (fileContent) {
prompt += `\n\n## Documento Anexado\n${fileContent}`;
}
if (diagnosticContext) {
prompt += `\n\n## Contexto Empresarial (Process Compass)`;
if (diagnosticContext.projectName) prompt += `\n**Projeto:** ${diagnosticContext.projectName}`;
if (diagnosticContext.clientName) prompt += `\n**Cliente:** ${diagnosticContext.clientName}`;
if (diagnosticContext.canvas?.length) {
prompt += `\n\n### Canvas BMC\n${diagnosticContext.canvas.map(b =>
`**${b.blockType}**: ${b.content || 'Sem conteúdo'}`).join('\n')}`;
}
if (diagnosticContext.swot?.analyses?.length) {
prompt += `\n\n### SWOT`;
diagnosticContext.swot.analyses.forEach(a => {
const items = diagnosticContext.swot!.items.filter(i => i.swotAnalysisId === a.id);
prompt += `\n**${a.name}**: F:${items.filter(i=>i.type==='strength').length} Fr:${items.filter(i=>i.type==='weakness').length} O:${items.filter(i=>i.type==='opportunity').length} A:${items.filter(i=>i.type==='threat').length}`;
});
}
if (diagnosticContext.pdca?.cycles?.length) {
prompt += `\n\n### PDCA\n${diagnosticContext.pdca.cycles.map(c => `**${c.title}** (${c.status})`).join('\n')}`;
}
if (diagnosticContext.processes?.processes?.length) {
prompt += `\n\n### Processos\n${diagnosticContext.processes.processes.map(p => `**${p.name}** (${p.department||'Geral'})`).join('\n')}`;
}
}
return prompt;
}

View File

@ -1,10 +1,33 @@
import type { Express, Request, Response } from "express";
import OpenAI from "openai";
import { chatStorage } from "./storage";
import { buildPromptWithContext } from "./prompt";
import { buildPromptWithContext, buildAgentPromptForChat } from "./prompt";
import { compassStorage } from "../../compass/storage";
import { PDFParse } from "pdf-parse";
import { learningService } from "../../learning/service";
import { manusService } from "../../manus/service";
const TOOL_LABELS: Record<string, string> = {
web_search: "🔍 Pesquisando na web",
deep_research: "🔬 Pesquisa profunda",
knowledge_query: "📚 Consultando base de conhecimento",
erp_query: "🏢 Consultando ERP",
bi_execute_query: "📊 Executando consulta BI",
bi_create_chart: "📈 Gerando gráfico",
bi_create_dashboard: "🗂️ Criando dashboard",
bi_list_tables: "📋 Listando tabelas",
bi_get_table_columns: "🔎 Verificando colunas",
bi_create_dataset: "🗃️ Criando dataset",
bi_stats: "📉 Carregando estatísticas BI",
semantic_search: "🧠 Buscando conhecimento",
learn_url: "📖 Aprendendo URL",
web_browse: "🌐 Navegando na web",
generate_chart: "📊 Gerando gráfico",
analyze_file: "📄 Analisando arquivo",
calculate: "🔢 Calculando",
list_agents: "🤖 Listando agentes",
call_agent: "📡 Comunicando com agente",
};
const openai = new OpenAI({
apiKey: process.env.AI_INTEGRATIONS_OPENAI_API_KEY,
@ -258,10 +281,10 @@ export function registerChatRoutes(app: Express): void {
}
}
const systemPrompt = buildPromptWithContext(knowledgeContext, processedFileContent || fileContent, diagnosticContext);
const systemPrompt = buildAgentPromptForChat(knowledgeContext, processedFileContent || fileContent, diagnosticContext);
const messages = await chatStorage.getMessagesByConversation(conversationId);
const chatMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
const loopMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
...messages.map((m) => ({
role: m.role as "user" | "assistant",
@ -273,20 +296,69 @@ export function registerChatRoutes(app: Express): void {
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
const stream = await openai.chat.completions.create({
model: "arcadia-agent",
messages: chatMessages,
stream: true,
max_tokens: 4096,
});
let fullResponse = "";
const MAX_STEPS = 8;
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content || "";
if (content) {
fullResponse += content;
res.write(`data: ${JSON.stringify({ content })}\n\n`);
for (let step = 0; step < MAX_STEPS; step++) {
const completion = await openai.chat.completions.create({
model: "arcadia-agent",
messages: loopMessages,
stream: false,
max_tokens: 3000,
});
const rawText = completion.choices[0]?.message?.content || "";
loopMessages.push({ role: "assistant", content: rawText });
// Try to parse as Manus JSON format
let parsed: { thought?: string; tool?: string; tool_input?: Record<string, any> } | null = null;
try {
const jsonMatch = rawText.match(/\{[\s\S]*\}/);
if (jsonMatch) parsed = JSON.parse(jsonMatch[0]);
} catch { /* not JSON */ }
if (parsed?.tool === "finish") {
// Stream the final answer
const answer = parsed.tool_input?.answer || rawText;
fullResponse = answer;
// Stream in small chunks for SSE effect
const words = answer.split(/(\s+)/);
for (const word of words) {
res.write(`data: ${JSON.stringify({ content: word })}\n\n`);
}
break;
} else if (parsed?.tool && parsed.tool !== "finish") {
// Execute tool and continue loop
const label = TOOL_LABELS[parsed.tool] || `⚙️ ${parsed.tool}`;
res.write(`data: ${JSON.stringify({ tool_status: label })}\n\n`);
try {
const toolResult = await manusService.executeTool(parsed.tool, parsed.tool_input || {}, userId);
loopMessages.push({
role: "user",
content: `[Resultado de ${parsed.tool}]: ${toolResult.output}`,
});
} catch (toolErr) {
loopMessages.push({
role: "user",
content: `[Erro em ${parsed.tool}]: Ferramenta falhou, tente outra abordagem.`,
});
}
} else if (step === 0) {
// First step returned plain text — force JSON retry
loopMessages.push({
role: "user",
content: `ERRO: Você não respondeu em JSON. Responda APENAS em JSON no formato: {"thought": "...", "tool": "finish", "tool_input": {"answer": "..."}}`,
});
continue;
} else {
// Still plain text after retry — stream as-is
fullResponse = rawText;
const words = rawText.split(/(\s+)/);
for (const word of words) {
res.write(`data: ${JSON.stringify({ content: word })}\n\n`);
}
break;
}
}

View File

@ -14,8 +14,10 @@ import { registerManusRoutes } from "./manus/routes";
import { registerCustomMcpRoutes } from "./mcp/routes";
import { registerAutomationRoutes } from "./automations/routes";
import { registerAutomationEngineRoutes } from "./automations/engine-proxy";
import { registerSkillRoutes } from "./skills/routes";
import { registerBiRoutes } from "./bi/routes";
import { registerBiEngineRoutes } from "./bi/engine-proxy";
import { registerMiroFlowRoutes } from "./miroflow/engine-proxy";
import { registerCommEngineRoutes } from "./communication/proxy";
import { registerLearningRoutes } from "./learning/routes";
import compassRoutes from "./compass/routes";
@ -68,6 +70,11 @@ export async function registerRoutes(
httpServer: Server,
app: Express
): Promise<Server> {
// Health check global — usado pelo Coolify e Docker healthcheck
app.get("/api/health", (_req, res) => {
res.json({ ok: true, service: "arcadia-suite", ts: new Date().toISOString() });
});
// Auth and session setup first
setupAuth(app);
@ -93,9 +100,11 @@ export async function registerRoutes(
registerCustomMcpRoutes(app);
registerAutomationRoutes(app);
registerAutomationEngineRoutes(app);
registerSkillRoutes(app);
registerBiRoutes(app);
app.use("/api/graph", graphRoutes);
registerBiEngineRoutes(app);
registerMiroFlowRoutes(app);
registerCommEngineRoutes(app);
registerLearningRoutes(app);
app.use("/api/compass", compassRoutes);

258
server/skills/engine.ts Normal file
View File

@ -0,0 +1,258 @@
/**
* SkillEngine runtime de execução de skills com herança POO.
*
* Suporta:
* - Herança declarativa via `extends` (lista de slugs)
* - Composição via dependências `/skill/...` no body
* - Triggers: manual | schedule | event | webhook | automation | agent | openclaw
* - Persistência de execuções em skill_executions
* - Audit hash (SHA-256) para imutabilidade
*/
import crypto from "crypto";
import { db } from "../../db/index";
import {
arcadiaSkills,
skillExecutions,
type ArcadiaSkill,
type InsertSkillExecution,
} from "@shared/schema";
import { eq, and, inArray } from "drizzle-orm";
import {
parseReferences,
ReferenceResolver,
interpolate,
type ExecutionContext,
} from "./reference-parser";
export type TriggerSource =
| "manual"
| "schedule"
| "automation"
| "agent"
| "webhook"
| "openclaw"
| "event";
export interface ExecuteOptions {
skillId: string;
inputParams?: Record<string, unknown>;
context: ExecutionContext;
triggeredBy?: TriggerSource;
automationId?: number;
parentExecutionId?: string;
}
export interface ExecuteResult {
executionId: string;
skillId: string;
status: "success" | "error";
output?: unknown;
error?: string;
durationMs: number;
resolvedBody?: string;
}
// ─── SkillEngine ──────────────────────────────────────────────────────────────
class SkillEngine {
// ── Carregar skill com cadeia de herança resolvida ──────────────────────────
async loadWithInheritance(skillId: string, tenantId?: number): Promise<ArcadiaSkill> {
const skill = await this.findById(skillId);
if (!skill) throw new Error(`Skill não encontrada: ${skillId}`);
if (!skill.extends || skill.extends.length === 0) return skill;
// Resolve cadeia de herança: mescla corpo e parâmetros dos pais
const parents = await this.resolveParents(skill.extends, tenantId);
return this.mergeInheritance(skill, parents);
}
private async findById(id: string): Promise<ArcadiaSkill | undefined> {
const [skill] = await db
.select()
.from(arcadiaSkills)
.where(eq(arcadiaSkills.id, id))
.limit(1);
return skill;
}
private async resolveParents(
extendsSlugs: string[],
tenantId?: number
): Promise<ArcadiaSkill[]> {
if (extendsSlugs.length === 0) return [];
const slugList = extendsSlugs.map((ref) => {
// aceita "/skill/namespace/slug" ou "namespace/slug" ou "slug"
const parts = ref.replace(/^\/skill\//, "").split("/");
return parts[parts.length - 1]; // pega o slug
});
const parents = await db
.select()
.from(arcadiaSkills)
.where(inArray(arcadiaSkills.slug, slugList));
return parents;
}
/**
* Merge simples de herança:
* - body do filho tem prioridade
* - parâmetros dos pais são fundidos (pai filho sobrescreve)
*/
private mergeInheritance(child: ArcadiaSkill, parents: ArcadiaSkill[]): ArcadiaSkill {
let mergedParams: Record<string, unknown> = {};
for (const parent of parents) {
if (parent.parametersSchema && typeof parent.parametersSchema === "object") {
mergedParams = { ...mergedParams, ...(parent.parametersSchema as object) };
}
}
if (child.parametersSchema && typeof child.parametersSchema === "object") {
mergedParams = { ...mergedParams, ...(child.parametersSchema as object) };
}
return {
...child,
parametersSchema: Object.keys(mergedParams).length > 0 ? mergedParams : child.parametersSchema,
};
}
// ── Executar ────────────────────────────────────────────────────────────────
async execute(opts: ExecuteOptions): Promise<ExecuteResult> {
const startedAt = Date.now();
// Criar registro de execução com status pending
const [execution] = await db
.insert(skillExecutions)
.values({
skillId: opts.skillId,
tenantId: opts.context.tenantId,
companyId: opts.context.companyId,
userId: opts.context.userId,
triggeredBy: opts.triggeredBy ?? "manual",
automationId: opts.automationId,
parentExecutionId: opts.parentExecutionId,
inputParams: opts.inputParams ?? {},
status: "running",
} as any)
.returning();
try {
const skill = await this.loadWithInheritance(opts.skillId, opts.context.tenantId);
// Verificar visibilidade
this.assertExecutable(skill, opts.context);
// Resolver referências no body
const body = skill.body ?? "";
const refs = parseReferences(body);
const resolver = new ReferenceResolver({
...opts.context,
vars: { ...(opts.inputParams ?? {}), ...(opts.context.vars ?? {}) },
});
const resolved = await resolver.resolveAll(refs);
const resolvedBody = interpolate(body, resolved);
const output: Record<string, unknown> = {
skill: skill.slug,
resolvedBody,
resolvedDependencies: resolved,
params: opts.inputParams,
};
const durationMs = Date.now() - startedAt;
const auditHash = this.buildAuditHash(execution.id, opts.skillId, output);
await db
.update(skillExecutions)
.set({
status: "success",
outputResult: output,
resolvedDependencies: resolved,
durationMs,
auditHash,
completedAt: new Date(),
})
.where(eq(skillExecutions.id, execution.id));
return {
executionId: execution.id,
skillId: opts.skillId,
status: "success",
output,
durationMs,
resolvedBody,
};
} catch (err: any) {
const durationMs = Date.now() - startedAt;
await db
.update(skillExecutions)
.set({
status: "error",
errorMessage: err.message,
durationMs,
completedAt: new Date(),
})
.where(eq(skillExecutions.id, execution.id));
return {
executionId: execution.id,
skillId: opts.skillId,
status: "error",
error: err.message,
durationMs,
};
}
}
// ── Verificações ────────────────────────────────────────────────────────────
private assertExecutable(skill: ArcadiaSkill, ctx: ExecutionContext): void {
if (skill.status !== "active") {
throw new Error(`Skill "${skill.slug}" não está ativa (status: ${skill.status})`);
}
if (skill.visibilityExecute === "private") {
// Apenas o criador pode executar
if (skill.createdBy && skill.createdBy !== ctx.userId) {
throw new Error(`Skill "${skill.slug}" é privada`);
}
}
}
// ── Audit ───────────────────────────────────────────────────────────────────
private buildAuditHash(executionId: string, skillId: string, output: unknown): string {
const payload = JSON.stringify({ executionId, skillId, output, ts: Date.now() });
return crypto.createHash("sha256").update(payload).digest("hex");
}
// ── Listar execuções ────────────────────────────────────────────────────────
async getExecutions(skillId: string, limit = 50) {
return db
.select()
.from(skillExecutions)
.where(eq(skillExecutions.skillId, skillId))
.orderBy(skillExecutions.startedAt)
.limit(limit);
}
// ── Health ──────────────────────────────────────────────────────────────────
async health(): Promise<{ ok: boolean; totalSkills: number }> {
const [{ count }] = await db
.select({ count: db.$count(arcadiaSkills) })
.from(arcadiaSkills);
return { ok: true, totalSkills: Number(count) };
}
}
export const skillEngine = new SkillEngine();

View File

@ -0,0 +1,182 @@
/**
* ReferenceParser resolve referências /tipo/caminho usadas no body das skills.
*
* Formatos suportados:
* /skill/namespace/slug executa outra skill
* /kg/node-id lê nó do Knowledge Graph
* /data/table/filter query ao banco Arcádia
* /var/nome variável de contexto da execução
* /tool/nome chama uma ferramenta registrada
* /agent/nome delega para um agente
* /file/caminho lê arquivo do storage
*/
import { db } from "../../db/index";
import { arcadiaSkills, graphNodes } from "@shared/schema";
import { eq, and } from "drizzle-orm";
export type ReferenceType = "skill" | "kg" | "data" | "var" | "tool" | "agent" | "file";
export interface ParsedReference {
raw: string; // texto original, ex: "/skill/system/base_report"
type: ReferenceType;
path: string[]; // segmentos após o tipo
}
export interface ResolvedReference {
ref: ParsedReference;
value: unknown;
error?: string;
}
export interface ExecutionContext {
tenantId?: number;
companyId?: number;
userId?: string;
vars?: Record<string, unknown>;
tools?: Record<string, (...args: unknown[]) => Promise<unknown>>;
}
// ─── Parser ──────────────────────────────────────────────────────────────────
const REF_REGEX = /\/([a-z]+)((?:\/[^\s/,})"']+)+)/g;
export function parseReferences(text: string): ParsedReference[] {
const refs: ParsedReference[] = [];
const seen = new Set<string>();
let match: RegExpExecArray | null;
while ((match = REF_REGEX.exec(text)) !== null) {
const raw = match[0];
if (seen.has(raw)) continue;
seen.add(raw);
const type = match[1] as ReferenceType;
const path = match[2].split("/").filter(Boolean);
if (isValidType(type)) {
refs.push({ raw, type, path });
}
}
return refs;
}
function isValidType(t: string): t is ReferenceType {
return ["skill", "kg", "data", "var", "tool", "agent", "file"].includes(t);
}
// ─── Resolver ─────────────────────────────────────────────────────────────────
export class ReferenceResolver {
constructor(private ctx: ExecutionContext) {}
async resolveAll(refs: ParsedReference[]): Promise<Record<string, ResolvedReference>> {
const results: Record<string, ResolvedReference> = {};
await Promise.all(
refs.map(async (ref) => {
try {
const value = await this.resolve(ref);
results[ref.raw] = { ref, value };
} catch (err: any) {
results[ref.raw] = { ref, value: null, error: err.message };
}
})
);
return results;
}
async resolve(ref: ParsedReference): Promise<unknown> {
switch (ref.type) {
case "skill":
return this.resolveSkill(ref.path);
case "kg":
return this.resolveKg(ref.path);
case "var":
return this.resolveVar(ref.path);
case "tool":
return this.resolveTool(ref.path);
case "data":
return this.resolveData(ref.path);
case "agent":
return { type: "agent_reference", name: ref.path.join("/") };
case "file":
return { type: "file_reference", path: ref.path.join("/") };
default:
throw new Error(`Tipo de referência desconhecido: ${(ref as any).type}`);
}
}
private async resolveSkill(path: string[]): Promise<unknown> {
// /skill/namespace/slug ou /skill/slug
const [ns, slug] = path.length >= 2 ? [path[0], path[1]] : ["tenant", path[0]];
const conditions = [eq(arcadiaSkills.slug, slug), eq(arcadiaSkills.namespace, ns)];
if (this.ctx.tenantId) {
conditions.push(eq(arcadiaSkills.tenantId, this.ctx.tenantId));
}
const [skill] = await db
.select()
.from(arcadiaSkills)
.where(and(...conditions))
.limit(1);
if (!skill) throw new Error(`Skill não encontrada: /${ns}/${slug}`);
return { type: "skill_ref", id: skill.id, slug: skill.slug, body: skill.body };
}
private async resolveKg(path: string[]): Promise<unknown> {
const nodeId = parseInt(path[0], 10);
if (isNaN(nodeId)) throw new Error(`ID de nó KG inválido: ${path[0]}`);
const [node] = await db
.select()
.from(graphNodes)
.where(eq(graphNodes.id, nodeId))
.limit(1);
if (!node) throw new Error(`Nó KG não encontrado: ${nodeId}`);
return node;
}
private resolveVar(path: string[]): unknown {
const name = path.join(".");
const vars = this.ctx.vars ?? {};
if (!(name in vars)) throw new Error(`Variável não definida: /var/${name}`);
return vars[name];
}
private async resolveTool(path: string[]): Promise<unknown> {
const name = path.join("/");
const tool = this.ctx.tools?.[name];
if (!tool) throw new Error(`Ferramenta não registrada: /tool/${name}`);
return { type: "tool_ref", name, callable: true };
}
private async resolveData(path: string[]): Promise<unknown> {
// /data/table/filter — placeholder; implementação por módulo
const [table, ...filterParts] = path;
return { type: "data_ref", table, filter: filterParts.join("/") };
}
}
// ─── Interpolação de template ─────────────────────────────────────────────────
/**
* Substitui referências resolvidas no body da skill.
* Ex: "Resultado de /var/input" "Resultado de <valor>"
*/
export function interpolate(
body: string,
resolved: Record<string, ResolvedReference>
): string {
let result = body;
for (const [raw, res] of Object.entries(resolved)) {
if (res.error) continue;
const replacement =
typeof res.value === "string" ? res.value : JSON.stringify(res.value);
result = result.replaceAll(raw, replacement);
}
return result;
}

424
server/skills/routes.ts Normal file
View File

@ -0,0 +1,424 @@
import type { Express, Request, Response } from "express";
import { db } from "../../db/index";
import {
arcadiaSkills,
skillExecutions,
insertArcadiaSkillSchema,
} from "@shared/schema";
import { eq, and, desc, ilike, or, ne } from "drizzle-orm";
import { z } from "zod";
import { skillEngine } from "./engine";
import { VersionManager } from "./versioning";
const versionManager = new VersionManager();
const executeBodySchema = z.object({
inputParams: z.record(z.unknown()).optional(),
triggeredBy: z
.enum(["manual", "schedule", "automation", "agent", "webhook", "openclaw", "event"])
.optional()
.default("manual"),
automationId: z.number().int().optional(),
parentExecutionId: z.string().uuid().optional(),
});
const listQuerySchema = z.object({
namespace: z.string().optional(),
status: z.string().optional(),
search: z.string().optional(),
limit: z.coerce.number().int().min(1).max(200).optional().default(50),
offset: z.coerce.number().int().min(0).optional().default(0),
});
function tenantId(req: Request): number | undefined {
return (req as any).user?.tenantId ?? (req as any).tenantId;
}
function userId(req: Request): string | undefined {
return (req as any).user?.id ?? (req as any).userId;
}
export function registerSkillRoutes(app: Express): void {
// ── Health ────────────────────────────────────────────────────────────────
app.get("/api/skills/health", async (_req: Request, res: Response) => {
try {
const status = await skillEngine.health();
res.json(status);
} catch (err: any) {
res.status(500).json({ ok: false, error: err.message });
}
});
// ── Listar skills ─────────────────────────────────────────────────────────
app.get("/api/skills", async (req: Request, res: Response) => {
try {
const q = listQuerySchema.parse(req.query);
const tid = tenantId(req);
const conditions: any[] = [];
if (tid) conditions.push(eq(arcadiaSkills.tenantId, tid));
if (q.namespace) conditions.push(eq(arcadiaSkills.namespace, q.namespace));
if (q.status) conditions.push(eq(arcadiaSkills.status, q.status as any));
if (q.search) {
conditions.push(
or(
ilike(arcadiaSkills.name, `%${q.search}%`),
ilike(arcadiaSkills.slug, `%${q.search}%`),
ilike(arcadiaSkills.description, `%${q.search}%`)
)
);
}
const skills = await db
.select()
.from(arcadiaSkills)
.where(conditions.length ? and(...conditions) : undefined)
.orderBy(desc(arcadiaSkills.createdAt))
.limit(q.limit)
.offset(q.offset);
res.json({ skills, total: skills.length });
} catch (err: any) {
res.status(400).json({ error: err.message });
}
});
// ── Criar skill ───────────────────────────────────────────────────────────
app.post("/api/skills", async (req: Request, res: Response) => {
try {
const tid = tenantId(req);
const uid = userId(req);
const data = insertArcadiaSkillSchema.parse({
...req.body,
tenantId: tid,
createdBy: uid,
});
const [skill] = await db.insert(arcadiaSkills).values(data).returning();
res.status(201).json(skill);
} catch (err: any) {
res.status(400).json({ error: err.message });
}
});
// ── Buscar skill por ID ───────────────────────────────────────────────────
app.get("/api/skills/:id", async (req: Request, res: Response) => {
try {
const [skill] = await db
.select()
.from(arcadiaSkills)
.where(eq(arcadiaSkills.id, req.params.id))
.limit(1);
if (!skill) return res.status(404).json({ error: "Skill não encontrada" });
res.json(skill);
} catch (err: any) {
res.status(400).json({ error: err.message });
}
});
// ── Atualizar skill ───────────────────────────────────────────────────────
app.put("/api/skills/:id", async (req: Request, res: Response) => {
try {
const [existing] = await db
.select()
.from(arcadiaSkills)
.where(eq(arcadiaSkills.id, req.params.id))
.limit(1);
if (!existing) return res.status(404).json({ error: "Skill não encontrada" });
const [updated] = await db
.update(arcadiaSkills)
.set({ ...req.body, updatedAt: new Date() })
.where(eq(arcadiaSkills.id, req.params.id))
.returning();
res.json(updated);
} catch (err: any) {
res.status(400).json({ error: err.message });
}
});
// ── Deletar skill ─────────────────────────────────────────────────────────
app.delete("/api/skills/:id", async (req: Request, res: Response) => {
try {
const result = await db
.delete(arcadiaSkills)
.where(eq(arcadiaSkills.id, req.params.id));
if ((result.rowCount ?? 0) === 0) {
return res.status(404).json({ error: "Skill não encontrada" });
}
res.json({ deleted: true });
} catch (err: any) {
res.status(400).json({ error: err.message });
}
});
// ── Executar skill ────────────────────────────────────────────────────────
app.post("/api/skills/:id/execute", async (req: Request, res: Response) => {
try {
const body = executeBodySchema.parse(req.body);
const result = await skillEngine.execute({
skillId: req.params.id,
inputParams: body.inputParams,
context: {
tenantId: tenantId(req),
userId: userId(req),
},
triggeredBy: body.triggeredBy,
automationId: body.automationId,
parentExecutionId: body.parentExecutionId,
});
const statusCode = result.status === "success" ? 200 : 500;
res.status(statusCode).json(result);
} catch (err: any) {
res.status(400).json({ error: err.message });
}
});
// ── Histórico de execuções ────────────────────────────────────────────────
app.get("/api/skills/:id/executions", async (req: Request, res: Response) => {
try {
const limit = Math.min(parseInt(req.query.limit as string) || 50, 200);
const executions = await skillEngine.getExecutions(req.params.id, limit);
res.json({ executions });
} catch (err: any) {
res.status(400).json({ error: err.message });
}
});
// ── Marketplace: listar skills do sistema disponíveis ────────────────────
app.get("/api/skills/marketplace", async (req: Request, res: Response) => {
try {
const search = req.query.search as string | undefined;
const tag = req.query.tag as string | undefined;
const tid = tenantId(req);
const conditions: any[] = [
eq(arcadiaSkills.namespace, "system"),
eq(arcadiaSkills.status, "active"),
];
if (search) {
conditions.push(
or(
ilike(arcadiaSkills.name, `%${search}%`),
ilike(arcadiaSkills.description, `%${search}%`),
ilike(arcadiaSkills.slug, `%${search}%`)
)
);
}
const skills = await db
.select()
.from(arcadiaSkills)
.where(and(...conditions))
.orderBy(desc(arcadiaSkills.createdAt))
.limit(100);
// Marcar quais já foram importadas pelo tenant
let importedSlugs: string[] = [];
if (tid) {
const tenantSkills = await db
.select({ slug: arcadiaSkills.slug })
.from(arcadiaSkills)
.where(and(eq(arcadiaSkills.tenantId, tid), ne(arcadiaSkills.namespace, "system")));
importedSlugs = tenantSkills.map(s => s.slug);
}
const result = skills
.filter(s => !tag || (s.tags ?? []).includes(tag))
.map(s => ({ ...s, imported: importedSlugs.includes(s.slug) }));
res.json({ skills: result });
} catch (err: any) {
res.status(400).json({ error: err.message });
}
});
// ── Marketplace: importar skill do sistema para o tenant ──────────────────
app.post("/api/skills/marketplace/:id/import", async (req: Request, res: Response) => {
try {
const tid = tenantId(req);
const uid = userId(req);
const [source] = await db
.select()
.from(arcadiaSkills)
.where(and(eq(arcadiaSkills.id, req.params.id), eq(arcadiaSkills.namespace, "system")))
.limit(1);
if (!source) return res.status(404).json({ error: "Skill não encontrada no marketplace" });
// Clonar para o namespace tenant
const [imported] = await db
.insert(arcadiaSkills)
.values({
name: source.name,
slug: source.slug,
description: source.description,
version: source.version,
icon: source.icon,
tags: source.tags,
namespace: "tenant",
tenantId: tid,
extends: [`/skill:system/${source.slug}`],
body: source.body,
parametersSchema: source.parametersSchema,
returnSchema: source.returnSchema,
triggerType: source.triggerType,
triggerConfig: source.triggerConfig,
status: "draft",
createdBy: uid,
} as any)
.returning();
res.status(201).json({ skill: imported, importedFrom: source.id });
} catch (err: any) {
res.status(400).json({ error: err.message });
}
});
// ── Execução por ID ───────────────────────────────────────────────────────
app.get("/api/skills/executions/:executionId", async (req: Request, res: Response) => {
try {
const [execution] = await db
.select()
.from(skillExecutions)
.where(eq(skillExecutions.id, req.params.executionId))
.limit(1);
if (!execution) return res.status(404).json({ error: "Execução não encontrada" });
res.json(execution);
} catch (err: any) {
res.status(400).json({ error: err.message });
}
});
// ── Versionamento: listar versões ──────────────────────────────────────────
app.get("/api/skills/:id/versions", async (req: Request, res: Response) => {
try {
const { id } = req.params;
const limit = parseInt(req.query.limit as string) || 20;
const offset = parseInt(req.query.offset as string) || 0;
const versions = await versionManager.listVersions(id, limit, offset);
res.json({ versions });
} catch (err: any) {
res.status(400).json({ error: err.message });
}
});
// ── Versionamento: criar versão ────────────────────────────────────────────
app.post("/api/skills/:id/versions", async (req: Request, res: Response) => {
try {
const { id } = req.params;
const { message } = req.body;
const uid = userId(req);
if (!message) {
return res.status(400).json({ error: "message required" });
}
const skill = await db
.select()
.from(arcadiaSkills)
.where(eq(arcadiaSkills.id, id))
.executeTakeFirst();
if (!skill) {
return res.status(404).json({ error: "Skill not found" });
}
const version = await versionManager.createVersion(
id,
message,
uid || "system",
skill.content
);
res.json({ version });
} catch (err: any) {
res.status(400).json({ error: err.message });
}
});
// ── Versionamento: rollback ────────────────────────────────────────────────
app.post("/api/skills/:id/rollback", async (req: Request, res: Response) => {
try {
const { id } = req.params;
const { sha } = req.body;
const uid = userId(req);
if (!sha) {
return res.status(400).json({ error: "sha required" });
}
const skill = await versionManager.rollbackToVersion(id, sha, uid || "system");
res.json({ skill, message: `Rolled back to ${sha.slice(0, 7)}` });
} catch (err: any) {
res.status(400).json({ error: err.message });
}
});
// ── Versionamento: fork ────────────────────────────────────────────────────
app.post("/api/skills/:id/fork", async (req: Request, res: Response) => {
try {
const { id } = req.params;
const { newSlug } = req.body;
const uid = userId(req);
if (!newSlug) {
return res.status(400).json({ error: "newSlug required" });
}
const skill = await versionManager.fork(id, newSlug, uid || "system");
res.json({ skill, message: `Forked to ${newSlug}` });
} catch (err: any) {
res.status(400).json({ error: err.message });
}
});
// ── Versionamento: diff ────────────────────────────────────────────────────
app.get("/api/skills/:id/diff", async (req: Request, res: Response) => {
try {
const { id } = req.params;
const { from, to } = req.query;
if (!from || !to) {
return res.status(400).json({ error: "from and to shas required" });
}
const diff = await versionManager.diffVersions(
id,
from as string,
to as string
);
res.json({ diff });
} catch (err: any) {
res.status(400).json({ error: err.message });
}
});
}

156
server/skills/versioning.ts Normal file
View File

@ -0,0 +1,156 @@
import crypto from 'crypto';
import { db } from '@/lib/db';
import { Skill } from './engine';
export interface SkillVersion {
id: string;
skillId: string;
sha: string;
message: string;
author: string;
timestamp: Date;
content: any;
}
export class VersionManager {
async createVersion(skillId: string, message: string, author: string, content: any): Promise<SkillVersion> {
const sha = this.generateSha(skillId, content);
const version = await db.insertInto('skill_versions').values({
skill_id: skillId,
sha,
message,
author,
content: JSON.stringify(content),
created_at: new Date()
}).returning('*').executeTakeFirst();
return this.mapVersion(version);
}
async listVersions(skillId: string, limit = 20, offset = 0): Promise<SkillVersion[]> {
const versions = await db
.selectFrom('skill_versions')
.where('skill_id', '=', skillId)
.orderBy('created_at', 'desc')
.limit(limit)
.offset(offset)
.selectAll()
.execute();
return versions.map(v => this.mapVersion(v));
}
async getVersion(skillId: string, sha: string): Promise<SkillVersion | null> {
const version = await db
.selectFrom('skill_versions')
.where('skill_id', '=', skillId)
.where('sha', '=', sha)
.selectAll()
.executeTakeFirst();
return version ? this.mapVersion(version) : null;
}
async rollbackToVersion(skillId: string, versionSha: string, author: string): Promise<Skill> {
const version = await this.getVersion(skillId, versionSha);
if (!version) throw new Error(`Version ${versionSha} not found`);
const content = JSON.parse(version.content);
// Update main skill table
await db
.updateTable('arcadia_skills')
.set({
content: JSON.stringify(content),
updated_at: new Date()
})
.where('id', '=', skillId)
.execute();
// Create rollback version
await this.createVersion(skillId, `Rollback to ${versionSha.slice(0, 7)}`, author, content);
return { id: skillId, ...content } as any;
}
async fork(skillId: string, newSlug: string, author: string): Promise<Skill> {
const original = await db
.selectFrom('arcadia_skills')
.where('id', '=', skillId)
.selectAll()
.executeTakeFirst();
if (!original) throw new Error(`Skill ${skillId} not found`);
const newId = crypto.randomUUID();
const content = JSON.parse(original.content);
const forked = await db
.insertInto('arcadia_skills')
.values({
id: newId,
slug: newSlug,
name: `${original.name} (fork)`,
description: `Fork of ${original.slug}`,
type: original.type,
content: JSON.stringify(content),
tenant_id: original.tenant_id,
status: 'draft',
created_at: new Date()
})
.returning('*')
.executeTakeFirst();
// Create initial version
await this.createVersion(newId, `Forked from ${original.slug}`, author, content);
return { id: newId, ...content } as any;
}
async diffVersions(skillId: string, sha1: string, sha2: string): Promise<any> {
const v1 = await this.getVersion(skillId, sha1);
const v2 = await this.getVersion(skillId, sha2);
if (!v1 || !v2) throw new Error('One or both versions not found');
const content1 = JSON.parse(v1.content);
const content2 = JSON.parse(v2.content);
return {
from: { sha: sha1, timestamp: v1.timestamp },
to: { sha: sha2, timestamp: v2.timestamp },
changes: this.computeDiff(content1, content2)
};
}
private generateSha(skillId: string, content: any): string {
const str = `${skillId}${JSON.stringify(content)}${Date.now()}`;
return crypto.createHash('sha256').update(str).digest('hex');
}
private computeDiff(obj1: any, obj2: any): Record<string, any> {
const diff: Record<string, any> = {};
const allKeys = new Set([...Object.keys(obj1), ...Object.keys(obj2)]);
for (const key of allKeys) {
if (JSON.stringify(obj1[key]) !== JSON.stringify(obj2[key])) {
diff[key] = { before: obj1[key], after: obj2[key] };
}
}
return diff;
}
private mapVersion(row: any): SkillVersion {
return {
id: row.id,
skillId: row.skill_id,
sha: row.sha,
message: row.message,
author: row.author,
timestamp: row.created_at,
content: JSON.parse(row.content)
};
}
}

View File

@ -69,10 +69,19 @@ export function registerSupersetRoutes(app: Express): void {
// Resolve slug → dashboard ID → embedded UUID
// Enviar session cookie junto com o Bearer JWT para que Flask-Login identifique
// o admin via user_loader (necessário quando o papel Public tem can_read on Dashboard)
const dashResp = await fetch(`${SUPERSET_URL}/api/v1/dashboard/${dashboardId}`, {
let dashResp = await fetch(`${SUPERSET_URL}/api/v1/dashboard/${dashboardId}`, {
headers: { "Authorization": `Bearer ${token}`, "Cookie": session },
signal: AbortSignal.timeout(FETCH_TIMEOUT),
});
// Token pode ter expirado — limpa cache e tenta uma vez com credenciais frescas
if (!dashResp.ok) {
clearTokenCache();
const fresh = await getServiceToken();
dashResp = await fetch(`${SUPERSET_URL}/api/v1/dashboard/${dashboardId}`, {
headers: { "Authorization": `Bearer ${fresh.token}`, "Cookie": fresh.session },
signal: AbortSignal.timeout(FETCH_TIMEOUT),
});
}
if (!dashResp.ok) {
return res.status(404).json({ error: `Dashboard '${dashboardId}' não encontrado no Superset` });
}

View File

@ -237,7 +237,7 @@ Instruções:
const openai = getOpenAI();
const response = await openai.chat.completions.create({
model: "gpt-4o",
model: "arcadia-agent",
messages: [
{ role: "system", content: systemPrompt },
...conversationHistory as any,
@ -248,7 +248,7 @@ Instruções:
const aiContent = response.choices[0]?.message?.content || "Desculpe, não consegui processar sua solicitação.";
const aiConversation = await supportStorage.createAiResponse(ticket.id, aiContent, "gpt-4o");
const aiConversation = await supportStorage.createAiResponse(ticket.id, aiContent, "arcadia-agent");
res.json(aiConversation);
} catch (error) {

View File

@ -757,7 +757,7 @@ router.post("/projects/:projectId/import-financial", requireAuth, upload.single(
const dataPreview = JSON.stringify(rawData.slice(0, 10), null, 2);
const agentResponse = await openai.chat.completions.create({
model: "gpt-4o",
model: "arcadia-agent",
messages: [
{
role: "system",
@ -917,7 +917,7 @@ router.post("/projects/:projectId/checklist/:itemId/agent-assist", requireAuth,
: `Empresa: ${project.companyName}\nSetor: ${project.sector}\n\nInformações fornecidas:\n${content}`;
const response = await openai.chat.completions.create({
model: "gpt-4o",
model: "arcadia-agent",
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: userContent }
@ -1853,7 +1853,7 @@ Para cada item, indique: item (texto), impact (low/medium/high), valuationReleva
Responda em JSON: { strengths: [...], weaknesses: [...], opportunities: [...], threats: [...] }`;
const completion = await openai.chat.completions.create({
model: "gpt-4o-mini",
model: "arcadia-agent",
messages: [{ role: "user", content: prompt }],
response_format: { type: "json_object" },
});
@ -1997,7 +1997,7 @@ SWOT: ${swot.length} itens
Responda de forma consultiva, em português, com foco em recomendações acionáveis.`;
const completion = await openai.chat.completions.create({
model: "gpt-4o-mini",
model: "arcadia-agent",
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: message },
@ -2076,7 +2076,7 @@ SWOT: ${swot.length} itens | PDCA: ${pdca.length} ações | Ativos: ${assets.len
Gere em formato HTML com seções claras. Use formatação profissional.`;
const completion = await openai.chat.completions.create({
model: "gpt-4o-mini",
model: "arcadia-agent",
messages: [{ role: "user", content: prompt }],
});

View File

@ -99,7 +99,7 @@ Nome do cliente: ${contactName}`;
messages.push({ role: "user", content: message });
const response = await openai.chat.completions.create({
model: "gpt-4o-mini",
model: "arcadia-agent",
messages,
max_tokens: 200,
temperature: 0.7,

View File

@ -7429,3 +7429,99 @@ export type SoeEvento = typeof soeEventos.$inferSelect;
export type InsertSoeEvento = z.infer<typeof insertSoeEventoSchema>;
export type SoeLancamento = typeof soeLancamentos.$inferSelect;
export type InsertSoeLancamento = z.infer<typeof insertSoeLancamentoSchema>;
// =============================================================================
// ARCÁDIA AGENTIC SUITE — Skills POO (Fase 1 — 2026-03-24)
// Modelo orientado a objetos: herança, composição, polimorfismo, multi-tenant
// NÃO remove xosSkillRegistry (modelo legado XOS continua intacto)
// =============================================================================
export const arcadiaSkills = pgTable("arcadia_skills", {
id: uuid("id").primaryKey().defaultRandom(),
// Identidade
name: varchar("name", { length: 255 }).notNull(),
slug: varchar("slug", { length: 255 }).notNull(),
description: text("description"),
version: varchar("version", { length: 50 }).notNull().default("1.0.0"),
icon: varchar("icon", { length: 100 }),
tags: text("tags").array(),
// Namespace multi-tenant (system > tenant > company > user)
namespace: varchar("namespace", { length: 20 }).notNull().default("tenant"), // 'system' | 'tenant' | 'company' | 'user'
tenantId: integer("tenant_id").references(() => tenants.id, { onDelete: "cascade" }),
companyId: integer("company_id"),
userId: varchar("user_id").references(() => users.id, { onDelete: "set null" }),
// Herança POO — lista de slugs: ['/skill:system/base_report']
extends: text("extends").array(),
// Interfaces/contratos implementados
implements: text("implements").array(),
// Encapsulamento
visibilityExecute: varchar("visibility_execute", { length: 20 }).default("public"), // 'public' | 'private' | 'protected'
visibilityParams: varchar("visibility_params", { length: 20 }).default("public"),
// Composição — dependências como referências /tipo/caminho
dependencies: text("dependencies").array(),
// Trigger (quando executar automaticamente)
triggerType: varchar("trigger_type", { length: 30 }), // 'schedule' | 'event' | 'manual' | 'webhook'
triggerConfig: jsonb("trigger_config"),
// Corpo da skill (Markdown com blocos /skill/, /kg/, /tool/, etc.)
body: text("body"),
// Schemas de entrada/saída
parametersSchema: jsonb("parameters_schema"),
returnSchema: jsonb("return_schema"),
// Estado
status: varchar("status", { length: 20 }).notNull().default("draft"), // 'draft' | 'active' | 'archived'
isSystem: boolean("is_system").default(false),
// Autoria e rastreamento
author: varchar("author", { length: 255 }),
createdBy: varchar("created_by").references(() => users.id, { onDelete: "set null" }),
createdAt: timestamp("created_at").default(sql`CURRENT_TIMESTAMP`).notNull(),
updatedAt: timestamp("updated_at").default(sql`CURRENT_TIMESTAMP`).notNull(),
});
export const skillExecutions = pgTable("skill_executions", {
id: uuid("id").primaryKey().defaultRandom(),
skillId: uuid("skill_id").notNull().references(() => arcadiaSkills.id, { onDelete: "cascade" }),
// Contexto de execução
tenantId: integer("tenant_id").references(() => tenants.id),
companyId: integer("company_id"),
userId: varchar("user_id").references(() => users.id, { onDelete: "set null" }),
// Origem da execução
triggeredBy: varchar("triggered_by", { length: 30 }), // 'manual' | 'schedule' | 'automation' | 'agent' | 'openclaw'
automationId: integer("automation_id"),
parentExecutionId: uuid("parent_execution_id"), // para skills compostas
// Dados
inputParams: jsonb("input_params"),
outputResult: jsonb("output_result"),
resolvedDependencies: jsonb("resolved_dependencies"), // cache das refs / resolvidas
// Estado
status: varchar("status", { length: 20 }).notNull().default("pending"), // 'pending' | 'running' | 'success' | 'error' | 'cancelled'
errorMessage: text("error_message"),
durationMs: integer("duration_ms"),
// Imutabilidade / auditoria
auditHash: varchar("audit_hash", { length: 64 }),
startedAt: timestamp("started_at").default(sql`CURRENT_TIMESTAMP`).notNull(),
completedAt: timestamp("completed_at"),
});
export const insertArcadiaSkillSchema = createInsertSchema(arcadiaSkills).omit({ id: true, createdAt: true, updatedAt: true });
export const insertSkillExecutionSchema = createInsertSchema(skillExecutions).omit({ id: true, startedAt: true });
export type ArcadiaSkill = typeof arcadiaSkills.$inferSelect;
export type InsertArcadiaSkill = z.infer<typeof insertArcadiaSkillSchema>;
export type SkillExecution = typeof skillExecutions.$inferSelect;
export type InsertSkillExecution = z.infer<typeof insertSkillExecutionSchema>;