Compare commits
19 Commits
5b35aa49a2
...
7528d4c36e
| Author | SHA1 | Date |
|---|---|---|
|
|
7528d4c36e | |
|
|
9232666b8c | |
|
|
76adec5893 | |
|
|
4ceb407505 | |
|
|
e5b915e7e8 | |
|
|
e4e809fa5b | |
|
|
c14f238fe9 | |
|
|
76e1d34f5d | |
|
|
60f1c5cb1d | |
|
|
071d74c7a3 | |
|
|
1a70e87cd4 | |
|
|
d53be76474 | |
|
|
4057821404 | |
|
|
194a8dcc1b | |
|
|
c9a0563422 | |
|
|
3c61acf4d9 | |
|
|
9bfc888d28 | |
|
|
66d8dfbfcc | |
|
|
1c6c3865e5 |
|
|
@ -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
|
||||||
|
|
@ -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 | - |
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -10,6 +10,8 @@ RUN npm run build
|
||||||
|
|
||||||
FROM node:20-alpine AS runner
|
FROM node:20-alpine AS runner
|
||||||
|
|
||||||
|
RUN apk add --no-cache p7zip
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY --from=builder /app/dist ./dist
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -70,6 +70,7 @@ const XosPipeline = lazy(() => import("@/pages/XosPipeline"));
|
||||||
const XosSupervisor = lazy(() => import("@/pages/XosSupervisor"));
|
const XosSupervisor = lazy(() => import("@/pages/XosSupervisor"));
|
||||||
const XosReports = lazy(() => import("@/pages/XosReports"));
|
const XosReports = lazy(() => import("@/pages/XosReports"));
|
||||||
const XosProtocols = lazy(() => import("@/pages/XosProtocols"));
|
const XosProtocols = lazy(() => import("@/pages/XosProtocols"));
|
||||||
|
const Skills = lazy(() => import("@/pages/Skills"));
|
||||||
|
|
||||||
|
|
||||||
function LoadingFallback() {
|
function LoadingFallback() {
|
||||||
|
|
@ -146,6 +147,7 @@ function Router() {
|
||||||
<ProtectedRoute path="/page-builder" component={PageBuilder} />
|
<ProtectedRoute path="/page-builder" component={PageBuilder} />
|
||||||
<ProtectedRoute path="/migration" component={Migration} />
|
<ProtectedRoute path="/migration" component={Migration} />
|
||||||
<ProtectedRoute path="/dev-center" component={DevCenter} />
|
<ProtectedRoute path="/dev-center" component={DevCenter} />
|
||||||
|
<ProtectedRoute path="/skills" component={Skills} />
|
||||||
<ProtectedRoute path="/page/:id" component={WorkspacePage} />
|
<ProtectedRoute path="/page/:id" component={WorkspacePage} />
|
||||||
<ProtectedRoute path="/app/:id" component={AppViewer} />
|
<ProtectedRoute path="/app/:id" component={AppViewer} />
|
||||||
<Route path="/auth" component={AuthPage} />
|
<Route path="/auth" component={AuthPage} />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -365,6 +365,14 @@ async function cancelManusRun(id: number): Promise<void> {
|
||||||
await fetch(`/api/manus/runs/${id}`, { method: "DELETE", credentials: "include" });
|
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 }> {
|
async function startManusRun(data: { prompt: string; attachedFiles?: AttachedFile[]; conversationHistory?: Array<{role: string; content: string}> }): Promise<{ runId: number }> {
|
||||||
const response = await fetch("/api/manus/run", {
|
const response = await fetch("/api/manus/run", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -1246,7 +1254,13 @@ export default function Agent() {
|
||||||
if (line.startsWith("data: ")) {
|
if (line.startsWith("data: ")) {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(line.slice(6));
|
const data = JSON.parse(line.slice(6));
|
||||||
|
if (data.tool_status) {
|
||||||
|
setCurrentToolName(data.tool_status);
|
||||||
|
setProcessingMode("using_tools");
|
||||||
|
}
|
||||||
if (data.content) {
|
if (data.content) {
|
||||||
|
setCurrentToolName(null);
|
||||||
|
setProcessingMode("idle");
|
||||||
fullContent += data.content;
|
fullContent += data.content;
|
||||||
setStreamingContent(fullContent);
|
setStreamingContent(fullContent);
|
||||||
}
|
}
|
||||||
|
|
@ -1485,8 +1499,21 @@ export default function Agent() {
|
||||||
Nova Tarefa
|
Nova Tarefa
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
<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>
|
</div>
|
||||||
<ScrollArea className="flex-1 px-2">
|
<ScrollArea className="flex-1 px-2">
|
||||||
{loadingRuns ? (
|
{loadingRuns ? (
|
||||||
|
|
@ -1498,10 +1525,10 @@ export default function Agent() {
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1 pb-2">
|
<div className="space-y-1 pb-2">
|
||||||
{manusRuns.map((run) => (
|
{manusRuns.map((run) => (
|
||||||
|
<div key={run.id} className="group relative">
|
||||||
<button
|
<button
|
||||||
key={run.id}
|
|
||||||
onClick={() => setSelectedRun(run.id)}
|
onClick={() => setSelectedRun(run.id)}
|
||||||
className={`w-full text-left p-2.5 rounded-lg transition-all ${
|
className={`w-full text-left p-2.5 rounded-lg transition-all pr-8 ${
|
||||||
selectedRun === run.id
|
selectedRun === run.id
|
||||||
? "bg-[#c89b3c]/20 border border-[#c89b3c]/50"
|
? "bg-[#c89b3c]/20 border border-[#c89b3c]/50"
|
||||||
: "hover:bg-[#162638] border border-transparent"
|
: "hover:bg-[#162638] border border-transparent"
|
||||||
|
|
@ -1514,6 +1541,19 @@ export default function Agent() {
|
||||||
<span className="text-[10px] text-white/40">{formatTime(run.createdAt)}</span>
|
<span className="text-[10px] text-white/40">{formatTime(run.createdAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ import {
|
||||||
X,
|
X,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
|
Brain,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import {
|
import {
|
||||||
|
|
@ -81,6 +82,7 @@ import {
|
||||||
Cell,
|
Cell,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { SupersetDashboard } from "@/components/SupersetDashboard";
|
import { SupersetDashboard } from "@/components/SupersetDashboard";
|
||||||
|
import { MiroFlowControl } from "@/components/MiroFlowControl";
|
||||||
|
|
||||||
interface BiStats {
|
interface BiStats {
|
||||||
dataSources: number;
|
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">
|
<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
|
<Settings className="w-4 h-4 mr-2" /> Insights
|
||||||
</TabsTrigger>
|
</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>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="overview" className="mt-0">
|
<TabsContent value="overview" className="mt-0">
|
||||||
|
|
@ -2936,6 +2941,9 @@ export default function BiWorkspace() {
|
||||||
<SupersetDashboard dashboardId="executive-summary" height="calc(100vh - 320px)" />
|
<SupersetDashboard dashboardId="executive-summary" height="calc(100vh - 320px)" />
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
<TabsContent value="cientifico" className="mt-0">
|
||||||
|
<MiroFlowControl />
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -275,7 +275,7 @@ export default function Migration() {
|
||||||
<div className="mt-2 border-2 border-dashed rounded-lg p-6 text-center">
|
<div className="mt-2 border-2 border-dashed rounded-lg p-6 text-center">
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept=".zip,.json,.csv"
|
accept=".zip,.json,.csv,.rar,.sql,.sql.gz"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
id="backup-file"
|
id="backup-file"
|
||||||
onChange={e => setUploadFile(e.target.files?.[0] || null)}
|
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">
|
<p className="mt-2 text-sm text-gray-500">
|
||||||
{uploadFile ? uploadFile.name : "Clique para selecionar"}
|
{uploadFile ? uploadFile.name : "Clique para selecionar"}
|
||||||
</p>
|
</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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1855,7 +1855,7 @@ export default function ProcessCompass() {
|
||||||
data-testid="search-projects"
|
data-testid="search-projects"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<Plus className="h-4 w-4 mr-2" /> Novo Projeto
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -4600,6 +4600,19 @@ export default function ProcessCompass() {
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="project-client">Cliente</Label>
|
<Label htmlFor="project-client">Cliente</Label>
|
||||||
|
{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>
|
<Select name="clientId" required>
|
||||||
<SelectTrigger data-testid="select-project-client">
|
<SelectTrigger data-testid="select-project-client">
|
||||||
<SelectValue placeholder="Selecione um cliente" />
|
<SelectValue placeholder="Selecione um cliente" />
|
||||||
|
|
@ -4612,6 +4625,7 @@ export default function ProcessCompass() {
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="project-description">Descrição</Label>
|
<Label htmlFor="project-description">Descrição</Label>
|
||||||
|
|
|
||||||
|
|
@ -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 há 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Link } from "wouter";
|
import { Link } from "wouter";
|
||||||
import { BrowserFrame } from "@/components/Browser/BrowserFrame";
|
import { BrowserFrame } from "@/components/Browser/BrowserFrame";
|
||||||
import {
|
import {
|
||||||
|
|
@ -13,6 +13,8 @@ import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
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 {
|
interface XosStats {
|
||||||
total_contacts: number;
|
total_contacts: number;
|
||||||
|
|
@ -66,6 +68,68 @@ interface Activity {
|
||||||
|
|
||||||
export default function XosCentral() {
|
export default function XosCentral() {
|
||||||
const [activeTab, setActiveTab] = useState("dashboard");
|
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>({
|
const { data: stats } = useQuery<XosStats>({
|
||||||
queryKey: ["/api/xos/stats"],
|
queryKey: ["/api/xos/stats"],
|
||||||
|
|
@ -177,7 +241,7 @@ export default function XosCentral() {
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button data-testid="button-new-contact">
|
<Button data-testid="button-new-contact" onClick={() => setIsNewSelectorOpen(true)}>
|
||||||
<PlusCircle className="h-4 w-4 mr-2" />
|
<PlusCircle className="h-4 w-4 mr-2" />
|
||||||
Novo
|
Novo
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -406,7 +470,7 @@ export default function XosCentral() {
|
||||||
<Filter className="h-4 w-4 mr-2" />
|
<Filter className="h-4 w-4 mr-2" />
|
||||||
Filtros
|
Filtros
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm">
|
<Button size="sm" onClick={() => setIsNewContactOpen(true)}>
|
||||||
<PlusCircle className="h-4 w-4 mr-2" />
|
<PlusCircle className="h-4 w-4 mr-2" />
|
||||||
Novo Contato
|
Novo Contato
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -478,7 +542,7 @@ export default function XosCentral() {
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle>Atividades</CardTitle>
|
<CardTitle>Atividades</CardTitle>
|
||||||
<Button size="sm">
|
<Button size="sm" onClick={() => setIsNewActivityOpen(true)}>
|
||||||
<PlusCircle className="h-4 w-4 mr-2" />
|
<PlusCircle className="h-4 w-4 mr-2" />
|
||||||
Nova Atividade
|
Nova Atividade
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -520,6 +584,175 @@ export default function XosCentral() {
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</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>
|
</BrowserFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,15 @@ services:
|
||||||
ERPNEXT_URL: ${ERPNEXT_URL:-http://erpnext:8080}
|
ERPNEXT_URL: ${ERPNEXT_URL:-http://erpnext:8080}
|
||||||
ERPNEXT_API_KEY: ${ERPNEXT_API_KEY:-}
|
ERPNEXT_API_KEY: ${ERPNEXT_API_KEY:-}
|
||||||
ERPNEXT_API_SECRET: ${ERPNEXT_API_SECRET:-}
|
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:
|
ports:
|
||||||
- "5000:5000"
|
- "5000:5000"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|
@ -195,6 +204,19 @@ services:
|
||||||
- arcadia-internal
|
- 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 ─────────────────────────────────────────────────────
|
# ── Microserviços Python ─────────────────────────────────────────────────────
|
||||||
contabil:
|
contabil:
|
||||||
build:
|
build:
|
||||||
|
|
@ -366,6 +388,11 @@ services:
|
||||||
volumes:
|
volumes:
|
||||||
- ./docker/superset/superset_config.py:/app/pythonpath/superset_config.py:ro
|
- ./docker/superset/superset_config.py:/app/pythonpath/superset_config.py:ro
|
||||||
- ./docker/superset/init.sh:/app/docker/init.sh: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
|
- superset_home:/app/superset_home
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
|
|
@ -389,6 +416,24 @@ services:
|
||||||
- "traefik.http.middlewares.superset-https-redirect.redirectscheme.permanent=true"
|
- "traefik.http.middlewares.superset-https-redirect.redirectscheme.permanent=true"
|
||||||
profiles: [bi]
|
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:
|
networks:
|
||||||
arcadia-internal:
|
arcadia-internal:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|
@ -403,9 +448,8 @@ volumes:
|
||||||
redis_data:
|
redis_data:
|
||||||
ollama_models:
|
ollama_models:
|
||||||
open_webui_data:
|
open_webui_data:
|
||||||
|
neo4j_data:
|
||||||
|
neo4j_logs:
|
||||||
|
|
||||||
plus_db:
|
plus_db:
|
||||||
plus_storage:
|
plus_storage:
|
||||||
superset_home:
|
superset_home:
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,25 @@ services:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
profiles: [bi]
|
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:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
uploads:
|
uploads:
|
||||||
superset_home:
|
superset_home:
|
||||||
|
neo4j_data:
|
||||||
|
neo4j_logs:
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,8 @@ model_list:
|
||||||
|
|
||||||
- model_name: arcadia-agent
|
- model_name: arcadia-agent
|
||||||
litellm_params:
|
litellm_params:
|
||||||
model: ollama/arcadia-agent
|
model: groq/llama-3.3-70b-versatile
|
||||||
api_base: os.environ/OLLAMA_BASE_URL
|
api_key: os.environ/GROQ_API_KEY
|
||||||
|
|
||||||
- model_name: nomic-embed-text
|
- model_name: nomic-embed-text
|
||||||
litellm_params:
|
litellm_params:
|
||||||
|
|
@ -70,10 +70,10 @@ model_list:
|
||||||
# api_key: os.environ/ANTHROPIC_API_KEY
|
# api_key: os.environ/ANTHROPIC_API_KEY
|
||||||
|
|
||||||
# ── TIER 3: Groq (opt-in — inferência rápida sem dados persistidos) ──────────
|
# ── TIER 3: Groq (opt-in — inferência rápida sem dados persistidos) ──────────
|
||||||
# - model_name: groq-llama
|
- model_name: groq-llama
|
||||||
# litellm_params:
|
litellm_params:
|
||||||
# model: groq/llama-3.3-70b-versatile
|
model: groq/llama-3.3-70b-versatile
|
||||||
# api_key: os.environ/GROQ_API_KEY
|
api_key: os.environ/GROQ_API_KEY
|
||||||
|
|
||||||
# ── Modelo padrão do Arcádia (Manus usa este) ─────────────────────────────────
|
# ── Modelo padrão do Arcádia (Manus usa este) ─────────────────────────────────
|
||||||
# Prioridade: DeepSeek R1 14B (TIER 2) → llama3.2 (fallback rápido)
|
# Prioridade: DeepSeek R1 14B (TIER 2) → llama3.2 (fallback rápido)
|
||||||
|
|
@ -87,9 +87,6 @@ router_settings:
|
||||||
fallbacks:
|
fallbacks:
|
||||||
- {"gpt-4o": ["arcadia-agent"]}
|
- {"gpt-4o": ["arcadia-agent"]}
|
||||||
- {"gpt-4o-mini": ["arcadia-agent"]}
|
- {"gpt-4o-mini": ["arcadia-agent"]}
|
||||||
- {"arcadia-default": ["llama3.2"]}
|
|
||||||
- {"arcadia-agent": ["llama3.2"]}
|
|
||||||
- {"arcadia-finetuned": ["deepseek-r1"]}
|
|
||||||
- {"arcadia-embed": ["nomic-embed-text"]}
|
- {"arcadia-embed": ["nomic-embed-text"]}
|
||||||
|
|
||||||
litellm_settings:
|
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 |
|
|
@ -46,6 +46,8 @@ SQLLAB_ASYNC_TIME_LIMIT_SEC = 300
|
||||||
|
|
||||||
# ── Branding Arcádia ──────────────────────────────────────────────────────────
|
# ── Branding Arcádia ──────────────────────────────────────────────────────────
|
||||||
APP_NAME = "Arcádia Insights"
|
APP_NAME = "Arcádia Insights"
|
||||||
|
APP_ICON = "/static/assets/images/arcadia_logo.png"
|
||||||
|
APP_ICON_WIDTH = 150
|
||||||
LOGO_TARGET_PATH = "/"
|
LOGO_TARGET_PATH = "/"
|
||||||
FAVICONS = [{"href": "/static/assets/images/favicon.png"}]
|
FAVICONS = [{"href": "/static/assets/images/favicon.png"}]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -800,7 +800,7 @@ ${JSON.stringify(sampleData.slice(0, 10), null, 2)}
|
||||||
Pergunta do usuário: ${question}`;
|
Pergunta do usuário: ${question}`;
|
||||||
|
|
||||||
const response = await openai.chat.completions.create({
|
const response = await openai.chat.completions.create({
|
||||||
model: "gpt-4o-mini",
|
model: "arcadia-agent",
|
||||||
messages: [
|
messages: [
|
||||||
{ role: "system", content: systemPrompt },
|
{ role: "system", content: systemPrompt },
|
||||||
{ role: "user", content: userPrompt }
|
{ role: "user", content: userPrompt }
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ export interface AgentThought {
|
||||||
|
|
||||||
export class ManusIntelligence {
|
export class ManusIntelligence {
|
||||||
private static instance: ManusIntelligence;
|
private static instance: ManusIntelligence;
|
||||||
private model = "gpt-4o";
|
private model = "arcadia-agent";
|
||||||
private callCount = 0;
|
private callCount = 0;
|
||||||
private tokenCount = 0;
|
private tokenCount = 0;
|
||||||
private errorCount = 0;
|
private errorCount = 0;
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ function requireAuth(req: Request, res: Response, next: NextFunction) {
|
||||||
// Apply auth to all routes
|
// Apply auth to all routes
|
||||||
router.use(requireAuth);
|
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> {
|
async function getTenantId(req: Request): Promise<number | null> {
|
||||||
const userId = (req.user as any).id;
|
const userId = (req.user as any).id;
|
||||||
const headerTenantId = req.headers["x-tenant-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);
|
const isMember = await compassStorage.isUserInTenant(userId, tenantId);
|
||||||
return isMember ? tenantId : null;
|
return isMember ? tenantId : null;
|
||||||
}
|
}
|
||||||
const tenants = await compassStorage.getUserTenants(userId);
|
const userTenants = await compassStorage.getUserTenants(userId);
|
||||||
return tenants.length > 0 ? tenants[0].id : null;
|
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
|
// Validate tenant membership
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import { manusService } from "./manus/service";
|
||||||
import { db } from "../db/index";
|
import { db } from "../db/index";
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
const DEV_AGENT_CONTEXT = `CONTEXTO ESPECIAL - DEV AGENT:
|
const DEV_AGENT_CONTEXT = `CONTEXTO ESPECIAL - DEV AGENT:
|
||||||
|
|
@ -77,9 +79,7 @@ Usuário: ${message}
|
||||||
|
|
||||||
Responda como Dev Agent, criando os recursos solicitados:`;
|
Responda como Dev Agent, criando os recursos solicitados:`;
|
||||||
|
|
||||||
const result = await manusService.run(userId, fullPrompt, []);
|
const responseContent = await manusService.runSync(userId, fullPrompt, []);
|
||||||
|
|
||||||
const responseContent = result.finalResponse || "Desculpe, não consegui processar sua solicitação.";
|
|
||||||
|
|
||||||
const jsonMatch = responseContent.match(/```json\s*([\s\S]*?)\s*```/);
|
const jsonMatch = responseContent.match(/```json\s*([\s\S]*?)\s*```/);
|
||||||
let action = null;
|
let action = null;
|
||||||
|
|
|
||||||
|
|
@ -174,7 +174,7 @@ Responda APENAS com o código, sem explicações adicionais.`;
|
||||||
: prompt;
|
: prompt;
|
||||||
|
|
||||||
const response = await openai.chat.completions.create({
|
const response = await openai.chat.completions.create({
|
||||||
model: "gpt-4o",
|
model: "arcadia-agent",
|
||||||
messages: [
|
messages: [
|
||||||
{ role: "system", content: systemPrompt },
|
{ role: "system", content: systemPrompt },
|
||||||
{ role: "user", content: userPrompt },
|
{ role: "user", content: userPrompt },
|
||||||
|
|
@ -228,7 +228,7 @@ Use português brasileiro.`;
|
||||||
: message;
|
: message;
|
||||||
|
|
||||||
const response = await openai.chat.completions.create({
|
const response = await openai.chat.completions.create({
|
||||||
model: "gpt-4o",
|
model: "arcadia-agent",
|
||||||
messages: [
|
messages: [
|
||||||
{ role: "system", content: systemPrompt },
|
{ role: "system", content: systemPrompt },
|
||||||
{ role: "user", content: userPrompt },
|
{ role: "user", content: userPrompt },
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
import type { Express, Request, Response } from "express";
|
import type { Express, Request, Response } from "express";
|
||||||
import { manusService } from "./service";
|
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 {
|
export function registerManusRoutes(app: Express): void {
|
||||||
const handleManusRun = async (req: Request, res: Response) => {
|
const handleManusRun = async (req: Request, res: Response) => {
|
||||||
|
|
@ -61,4 +64,40 @@ export function registerManusRoutes(app: Express): void {
|
||||||
res.status(500).json({ error: "Failed to get runs" });
|
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" });
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,26 +14,73 @@ const openai = new OpenAI({
|
||||||
baseURL: process.env.AI_INTEGRATIONS_OPENAI_BASE_URL,
|
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:
|
IDENTIDADE:
|
||||||
1. Responda APENAS o que foi perguntado. Seja direto e objetivo.
|
- Você é o Manus, IA da Arcádia Suite. Se perguntado: "Sou o Manus, agente IA da Arcádia Suite."
|
||||||
2. NUNCA adicione SWOT, PDCA, Canvas, matrizes ou análises não pedidas.
|
- Não revele detalhes técnicos da infraestrutura (modelos, APIs, servidores).
|
||||||
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.
|
|
||||||
|
|
||||||
FERRAMENTAS (use só 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 só 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()}
|
${getToolsDescription()}
|
||||||
|
|
||||||
FORMATO OBRIGATÓRIO:
|
FORMATO DE RESPOSTA OBRIGATÓRIO (JSON):
|
||||||
{"thought": "raciocínio breve", "tool": "finish", "tool_input": {"answer": "resposta direta"}}
|
{"thought": "raciocínio sobre o próximo passo", "tool": "nome_ferramenta", "tool_input": {"param": "valor"}}
|
||||||
|
|
||||||
Com ferramenta:
|
Para concluir:
|
||||||
{"thought": "raciocínio", "tool": "nome_ferramenta", "tool_input": {"param": "valor"}}`;
|
{"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 {
|
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 {
|
try {
|
||||||
switch (tool) {
|
switch (tool) {
|
||||||
case "web_search":
|
case "web_search":
|
||||||
|
|
@ -2137,6 +2184,19 @@ class ManusService extends EventEmitter {
|
||||||
return { runId: run.id };
|
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}>) {
|
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;
|
let userPrompt = prompt;
|
||||||
if (attachedFiles && attachedFiles.length > 0) {
|
if (attachedFiles && attachedFiles.length > 0) {
|
||||||
|
|
@ -2170,13 +2230,25 @@ class ManusService extends EventEmitter {
|
||||||
while (step < maxSteps && !finished) {
|
while (step < maxSteps && !finished) {
|
||||||
step++;
|
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 {
|
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",
|
model: "arcadia-agent",
|
||||||
messages,
|
messages,
|
||||||
temperature: 0.2,
|
temperature: 0.2,
|
||||||
max_tokens: 1500,
|
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 || "";
|
const content = response.choices[0]?.message?.content || "";
|
||||||
messages.push({ role: "assistant", content });
|
messages.push({ role: "assistant", content });
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,8 @@ router.post('/upload', upload.single('file'), async (req, res) => {
|
||||||
if (fileName.endsWith('.zip')) sourceType = 'mongodb';
|
if (fileName.endsWith('.zip')) sourceType = 'mongodb';
|
||||||
else if (fileName.endsWith('.json')) sourceType = 'json';
|
else if (fileName.endsWith('.json')) sourceType = 'json';
|
||||||
else if (fileName.endsWith('.csv')) sourceType = 'csv';
|
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({
|
const [job] = await db.insert(migrationJobs).values({
|
||||||
name: name || `Migração ${new Date().toLocaleDateString('pt-BR')}`,
|
name: name || `Migração ${new Date().toLocaleDateString('pt-BR')}`,
|
||||||
|
|
@ -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));
|
const [updatedJob] = await db.select().from(migrationJobs).where(eq(migrationJobs.id, job.id));
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export { registerMiroFlowRoutes } from "./engine-proxy";
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit 9d180f1eeb5e301c440ad7ce8b12c14222779040
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit bd7ec41b51dd89f4c5551e3d27917b41af4a3793
|
||||||
|
|
@ -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__))
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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:
|
IDENTIDADE:
|
||||||
- Roda localmente com LLM open-source. Se perguntado: "Sou o Manus, IA local da Arcádia Suite."
|
- Você é o Manus, assistente empresarial da Arcádia Suite.
|
||||||
- NÃO é OpenAI, ChatGPT ou nenhum serviço externo.
|
- 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:
|
COMPORTAMENTO:
|
||||||
- Responda APENAS o que foi perguntado. Seja direto e objetivo.
|
- Seja direto e objetivo. Responda exatamente o que foi perguntado.
|
||||||
- Para cálculos, perguntas simples e saudações: responda diretamente.
|
- Para cálculos e perguntas simples: responda diretamente, sem rodeios.
|
||||||
- NUNCA adicione SWOT, Canvas, PDCA, matrizes ou frameworks não solicitados.
|
- 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.
|
- NUNCA adicione rodapés automáticos ou citações de fonte não pedidas.
|
||||||
- Quando não souber algo, diga claramente sem inventar.
|
- 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 {
|
export interface DiagnosticContext {
|
||||||
canvas?: any[];
|
canvas?: any[];
|
||||||
|
|
@ -116,3 +134,79 @@ ${diagnosticContext.requirements.map(req =>
|
||||||
|
|
||||||
return prompt;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,33 @@
|
||||||
import type { Express, Request, Response } from "express";
|
import type { Express, Request, Response } from "express";
|
||||||
import OpenAI from "openai";
|
import OpenAI from "openai";
|
||||||
import { chatStorage } from "./storage";
|
import { chatStorage } from "./storage";
|
||||||
import { buildPromptWithContext } from "./prompt";
|
import { buildPromptWithContext, buildAgentPromptForChat } from "./prompt";
|
||||||
import { compassStorage } from "../../compass/storage";
|
import { compassStorage } from "../../compass/storage";
|
||||||
import { PDFParse } from "pdf-parse";
|
import { PDFParse } from "pdf-parse";
|
||||||
import { learningService } from "../../learning/service";
|
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({
|
const openai = new OpenAI({
|
||||||
apiKey: process.env.AI_INTEGRATIONS_OPENAI_API_KEY,
|
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 messages = await chatStorage.getMessagesByConversation(conversationId);
|
||||||
const chatMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
const loopMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||||
{ role: "system", content: systemPrompt },
|
{ role: "system", content: systemPrompt },
|
||||||
...messages.map((m) => ({
|
...messages.map((m) => ({
|
||||||
role: m.role as "user" | "assistant",
|
role: m.role as "user" | "assistant",
|
||||||
|
|
@ -273,20 +296,69 @@ export function registerChatRoutes(app: Express): void {
|
||||||
res.setHeader("Cache-Control", "no-cache");
|
res.setHeader("Cache-Control", "no-cache");
|
||||||
res.setHeader("Connection", "keep-alive");
|
res.setHeader("Connection", "keep-alive");
|
||||||
|
|
||||||
const stream = await openai.chat.completions.create({
|
let fullResponse = "";
|
||||||
|
const MAX_STEPS = 8;
|
||||||
|
|
||||||
|
for (let step = 0; step < MAX_STEPS; step++) {
|
||||||
|
const completion = await openai.chat.completions.create({
|
||||||
model: "arcadia-agent",
|
model: "arcadia-agent",
|
||||||
messages: chatMessages,
|
messages: loopMessages,
|
||||||
stream: true,
|
stream: false,
|
||||||
max_tokens: 4096,
|
max_tokens: 3000,
|
||||||
});
|
});
|
||||||
|
|
||||||
let fullResponse = "";
|
const rawText = completion.choices[0]?.message?.content || "";
|
||||||
|
loopMessages.push({ role: "assistant", content: rawText });
|
||||||
|
|
||||||
for await (const chunk of stream) {
|
// Try to parse as Manus JSON format
|
||||||
const content = chunk.choices[0]?.delta?.content || "";
|
let parsed: { thought?: string; tool?: string; tool_input?: Record<string, any> } | null = null;
|
||||||
if (content) {
|
try {
|
||||||
fullResponse += content;
|
const jsonMatch = rawText.match(/\{[\s\S]*\}/);
|
||||||
res.write(`data: ${JSON.stringify({ content })}\n\n`);
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,10 @@ import { registerManusRoutes } from "./manus/routes";
|
||||||
import { registerCustomMcpRoutes } from "./mcp/routes";
|
import { registerCustomMcpRoutes } from "./mcp/routes";
|
||||||
import { registerAutomationRoutes } from "./automations/routes";
|
import { registerAutomationRoutes } from "./automations/routes";
|
||||||
import { registerAutomationEngineRoutes } from "./automations/engine-proxy";
|
import { registerAutomationEngineRoutes } from "./automations/engine-proxy";
|
||||||
|
import { registerSkillRoutes } from "./skills/routes";
|
||||||
import { registerBiRoutes } from "./bi/routes";
|
import { registerBiRoutes } from "./bi/routes";
|
||||||
import { registerBiEngineRoutes } from "./bi/engine-proxy";
|
import { registerBiEngineRoutes } from "./bi/engine-proxy";
|
||||||
|
import { registerMiroFlowRoutes } from "./miroflow/engine-proxy";
|
||||||
import { registerCommEngineRoutes } from "./communication/proxy";
|
import { registerCommEngineRoutes } from "./communication/proxy";
|
||||||
import { registerLearningRoutes } from "./learning/routes";
|
import { registerLearningRoutes } from "./learning/routes";
|
||||||
import compassRoutes from "./compass/routes";
|
import compassRoutes from "./compass/routes";
|
||||||
|
|
@ -68,6 +70,11 @@ export async function registerRoutes(
|
||||||
httpServer: Server,
|
httpServer: Server,
|
||||||
app: Express
|
app: Express
|
||||||
): Promise<Server> {
|
): 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
|
// Auth and session setup first
|
||||||
setupAuth(app);
|
setupAuth(app);
|
||||||
|
|
||||||
|
|
@ -93,9 +100,11 @@ export async function registerRoutes(
|
||||||
registerCustomMcpRoutes(app);
|
registerCustomMcpRoutes(app);
|
||||||
registerAutomationRoutes(app);
|
registerAutomationRoutes(app);
|
||||||
registerAutomationEngineRoutes(app);
|
registerAutomationEngineRoutes(app);
|
||||||
|
registerSkillRoutes(app);
|
||||||
registerBiRoutes(app);
|
registerBiRoutes(app);
|
||||||
app.use("/api/graph", graphRoutes);
|
app.use("/api/graph", graphRoutes);
|
||||||
registerBiEngineRoutes(app);
|
registerBiEngineRoutes(app);
|
||||||
|
registerMiroFlowRoutes(app);
|
||||||
registerCommEngineRoutes(app);
|
registerCommEngineRoutes(app);
|
||||||
registerLearningRoutes(app);
|
registerLearningRoutes(app);
|
||||||
app.use("/api/compass", compassRoutes);
|
app.use("/api/compass", compassRoutes);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -69,10 +69,19 @@ export function registerSupersetRoutes(app: Express): void {
|
||||||
// Resolve slug → dashboard ID → embedded UUID
|
// Resolve slug → dashboard ID → embedded UUID
|
||||||
// Enviar session cookie junto com o Bearer JWT para que Flask-Login identifique
|
// 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)
|
// 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 },
|
headers: { "Authorization": `Bearer ${token}`, "Cookie": session },
|
||||||
signal: AbortSignal.timeout(FETCH_TIMEOUT),
|
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) {
|
if (!dashResp.ok) {
|
||||||
return res.status(404).json({ error: `Dashboard '${dashboardId}' não encontrado no Superset` });
|
return res.status(404).json({ error: `Dashboard '${dashboardId}' não encontrado no Superset` });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -237,7 +237,7 @@ Instruções:
|
||||||
|
|
||||||
const openai = getOpenAI();
|
const openai = getOpenAI();
|
||||||
const response = await openai.chat.completions.create({
|
const response = await openai.chat.completions.create({
|
||||||
model: "gpt-4o",
|
model: "arcadia-agent",
|
||||||
messages: [
|
messages: [
|
||||||
{ role: "system", content: systemPrompt },
|
{ role: "system", content: systemPrompt },
|
||||||
...conversationHistory as any,
|
...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 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);
|
res.json(aiConversation);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -757,7 +757,7 @@ router.post("/projects/:projectId/import-financial", requireAuth, upload.single(
|
||||||
const dataPreview = JSON.stringify(rawData.slice(0, 10), null, 2);
|
const dataPreview = JSON.stringify(rawData.slice(0, 10), null, 2);
|
||||||
|
|
||||||
const agentResponse = await openai.chat.completions.create({
|
const agentResponse = await openai.chat.completions.create({
|
||||||
model: "gpt-4o",
|
model: "arcadia-agent",
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
role: "system",
|
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}`;
|
: `Empresa: ${project.companyName}\nSetor: ${project.sector}\n\nInformações fornecidas:\n${content}`;
|
||||||
|
|
||||||
const response = await openai.chat.completions.create({
|
const response = await openai.chat.completions.create({
|
||||||
model: "gpt-4o",
|
model: "arcadia-agent",
|
||||||
messages: [
|
messages: [
|
||||||
{ role: "system", content: systemPrompt },
|
{ role: "system", content: systemPrompt },
|
||||||
{ role: "user", content: userContent }
|
{ 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: [...] }`;
|
Responda em JSON: { strengths: [...], weaknesses: [...], opportunities: [...], threats: [...] }`;
|
||||||
|
|
||||||
const completion = await openai.chat.completions.create({
|
const completion = await openai.chat.completions.create({
|
||||||
model: "gpt-4o-mini",
|
model: "arcadia-agent",
|
||||||
messages: [{ role: "user", content: prompt }],
|
messages: [{ role: "user", content: prompt }],
|
||||||
response_format: { type: "json_object" },
|
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.`;
|
Responda de forma consultiva, em português, com foco em recomendações acionáveis.`;
|
||||||
|
|
||||||
const completion = await openai.chat.completions.create({
|
const completion = await openai.chat.completions.create({
|
||||||
model: "gpt-4o-mini",
|
model: "arcadia-agent",
|
||||||
messages: [
|
messages: [
|
||||||
{ role: "system", content: systemPrompt },
|
{ role: "system", content: systemPrompt },
|
||||||
{ role: "user", content: message },
|
{ 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.`;
|
Gere em formato HTML com seções claras. Use formatação profissional.`;
|
||||||
|
|
||||||
const completion = await openai.chat.completions.create({
|
const completion = await openai.chat.completions.create({
|
||||||
model: "gpt-4o-mini",
|
model: "arcadia-agent",
|
||||||
messages: [{ role: "user", content: prompt }],
|
messages: [{ role: "user", content: prompt }],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,7 @@ Nome do cliente: ${contactName}`;
|
||||||
messages.push({ role: "user", content: message });
|
messages.push({ role: "user", content: message });
|
||||||
|
|
||||||
const response = await openai.chat.completions.create({
|
const response = await openai.chat.completions.create({
|
||||||
model: "gpt-4o-mini",
|
model: "arcadia-agent",
|
||||||
messages,
|
messages,
|
||||||
max_tokens: 200,
|
max_tokens: 200,
|
||||||
temperature: 0.7,
|
temperature: 0.7,
|
||||||
|
|
|
||||||
|
|
@ -7429,3 +7429,99 @@ export type SoeEvento = typeof soeEventos.$inferSelect;
|
||||||
export type InsertSoeEvento = z.infer<typeof insertSoeEventoSchema>;
|
export type InsertSoeEvento = z.infer<typeof insertSoeEventoSchema>;
|
||||||
export type SoeLancamento = typeof soeLancamentos.$inferSelect;
|
export type SoeLancamento = typeof soeLancamentos.$inferSelect;
|
||||||
export type InsertSoeLancamento = z.infer<typeof insertSoeLancamentoSchema>;
|
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>;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue