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
|
||||
|
||||
RUN apk add --no-cache p7zip
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
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 XosReports = lazy(() => import("@/pages/XosReports"));
|
||||
const XosProtocols = lazy(() => import("@/pages/XosProtocols"));
|
||||
const Skills = lazy(() => import("@/pages/Skills"));
|
||||
|
||||
|
||||
function LoadingFallback() {
|
||||
|
|
@ -146,6 +147,7 @@ function Router() {
|
|||
<ProtectedRoute path="/page-builder" component={PageBuilder} />
|
||||
<ProtectedRoute path="/migration" component={Migration} />
|
||||
<ProtectedRoute path="/dev-center" component={DevCenter} />
|
||||
<ProtectedRoute path="/skills" component={Skills} />
|
||||
<ProtectedRoute path="/page/:id" component={WorkspacePage} />
|
||||
<ProtectedRoute path="/app/:id" component={AppViewer} />
|
||||
<Route path="/auth" component={AuthPage} />
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
}
|
||||
|
||||
async function deleteManusRun(id: number): Promise<void> {
|
||||
await fetch(`/api/manus/runs/${id}`, { method: "DELETE", credentials: "include" });
|
||||
}
|
||||
|
||||
async function deleteAllManusRuns(): Promise<void> {
|
||||
await fetch("/api/manus/runs", { method: "DELETE", credentials: "include" });
|
||||
}
|
||||
|
||||
async function startManusRun(data: { prompt: string; attachedFiles?: AttachedFile[]; conversationHistory?: Array<{role: string; content: string}> }): Promise<{ runId: number }> {
|
||||
const response = await fetch("/api/manus/run", {
|
||||
method: "POST",
|
||||
|
|
@ -1246,7 +1254,13 @@ export default function Agent() {
|
|||
if (line.startsWith("data: ")) {
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
if (data.tool_status) {
|
||||
setCurrentToolName(data.tool_status);
|
||||
setProcessingMode("using_tools");
|
||||
}
|
||||
if (data.content) {
|
||||
setCurrentToolName(null);
|
||||
setProcessingMode("idle");
|
||||
fullContent += data.content;
|
||||
setStreamingContent(fullContent);
|
||||
}
|
||||
|
|
@ -1485,8 +1499,21 @@ export default function Agent() {
|
|||
Nova Tarefa
|
||||
</Button>
|
||||
</div>
|
||||
<div className="px-3 pt-3 pb-1">
|
||||
<div className="px-3 pt-3 pb-1 flex items-center justify-between">
|
||||
<p className="text-xs text-white/40 uppercase tracking-wider font-medium">Todas as Tarefas</p>
|
||||
{manusRuns.length > 0 && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
await deleteAllManusRuns();
|
||||
setSelectedRun(null);
|
||||
queryClient.invalidateQueries({ queryKey: ["manus-runs"] });
|
||||
}}
|
||||
className="text-[10px] text-white/30 hover:text-red-400 transition-colors"
|
||||
title="Limpar histórico"
|
||||
>
|
||||
Limpar tudo
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<ScrollArea className="flex-1 px-2">
|
||||
{loadingRuns ? (
|
||||
|
|
@ -1498,22 +1525,35 @@ export default function Agent() {
|
|||
) : (
|
||||
<div className="space-y-1 pb-2">
|
||||
{manusRuns.map((run) => (
|
||||
<button
|
||||
key={run.id}
|
||||
onClick={() => setSelectedRun(run.id)}
|
||||
className={`w-full text-left p-2.5 rounded-lg transition-all ${
|
||||
selectedRun === run.id
|
||||
? "bg-[#c89b3c]/20 border border-[#c89b3c]/50"
|
||||
: "hover:bg-[#162638] border border-transparent"
|
||||
}`}
|
||||
data-testid={`run-${run.id}`}
|
||||
>
|
||||
<p className="text-xs text-white line-clamp-2 mb-1.5">{run.prompt}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusBadge(run.status)}
|
||||
<span className="text-[10px] text-white/40">{formatTime(run.createdAt)}</span>
|
||||
</div>
|
||||
</button>
|
||||
<div key={run.id} className="group relative">
|
||||
<button
|
||||
onClick={() => setSelectedRun(run.id)}
|
||||
className={`w-full text-left p-2.5 rounded-lg transition-all pr-8 ${
|
||||
selectedRun === run.id
|
||||
? "bg-[#c89b3c]/20 border border-[#c89b3c]/50"
|
||||
: "hover:bg-[#162638] border border-transparent"
|
||||
}`}
|
||||
data-testid={`run-${run.id}`}
|
||||
>
|
||||
<p className="text-xs text-white line-clamp-2 mb-1.5">{run.prompt}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusBadge(run.status)}
|
||||
<span className="text-[10px] text-white/40">{formatTime(run.createdAt)}</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
await deleteManusRun(run.id);
|
||||
if (selectedRun === run.id) setSelectedRun(null);
|
||||
queryClient.invalidateQueries({ queryKey: ["manus-runs"] });
|
||||
}}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 text-white/30 hover:text-red-400 transition-all"
|
||||
title="Excluir"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ import {
|
|||
X,
|
||||
Sparkles,
|
||||
MessageSquare,
|
||||
Brain,
|
||||
} from "lucide-react";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
|
|
@ -81,6 +82,7 @@ import {
|
|||
Cell,
|
||||
} from "recharts";
|
||||
import { SupersetDashboard } from "@/components/SupersetDashboard";
|
||||
import { MiroFlowControl } from "@/components/MiroFlowControl";
|
||||
|
||||
interface BiStats {
|
||||
dataSources: number;
|
||||
|
|
@ -2893,6 +2895,9 @@ export default function BiWorkspace() {
|
|||
<TabsTrigger value="advanced" className="data-[state=active]:bg-[#c89b3c] data-[state=active]:text-[#1f334d] text-white/70">
|
||||
<Settings className="w-4 h-4 mr-2" /> Insights
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="cientifico" className="data-[state=active]:bg-[#c89b3c] data-[state=active]:text-[#1f334d] text-white/70">
|
||||
<Brain className="w-4 h-4 mr-2" /> Científico
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="mt-0">
|
||||
|
|
@ -2936,6 +2941,9 @@ export default function BiWorkspace() {
|
|||
<SupersetDashboard dashboardId="executive-summary" height="calc(100vh - 320px)" />
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="cientifico" className="mt-0">
|
||||
<MiroFlowControl />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -275,7 +275,7 @@ export default function Migration() {
|
|||
<div className="mt-2 border-2 border-dashed rounded-lg p-6 text-center">
|
||||
<input
|
||||
type="file"
|
||||
accept=".zip,.json,.csv"
|
||||
accept=".zip,.json,.csv,.rar,.sql,.sql.gz"
|
||||
className="hidden"
|
||||
id="backup-file"
|
||||
onChange={e => setUploadFile(e.target.files?.[0] || null)}
|
||||
|
|
@ -286,7 +286,7 @@ export default function Migration() {
|
|||
<p className="mt-2 text-sm text-gray-500">
|
||||
{uploadFile ? uploadFile.name : "Clique para selecionar"}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 mt-1">ZIP (MongoDB), JSON ou CSV</p>
|
||||
<p className="text-xs text-gray-400 mt-1">ZIP (MongoDB), RAR, SQL, SQL.GZ, JSON ou CSV</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1855,7 +1855,7 @@ export default function ProcessCompass() {
|
|||
data-testid="search-projects"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={() => setShowNewProjectDialog(true)} disabled={clients.length === 0} data-testid="btn-new-project">
|
||||
<Button onClick={() => setShowNewProjectDialog(true)} data-testid="btn-new-project">
|
||||
<Plus className="h-4 w-4 mr-2" /> Novo Projeto
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -4600,18 +4600,32 @@ export default function ProcessCompass() {
|
|||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="project-client">Cliente</Label>
|
||||
<Select name="clientId" required>
|
||||
<SelectTrigger data-testid="select-project-client">
|
||||
<SelectValue placeholder="Selecione um cliente" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{clients.map(client => (
|
||||
<SelectItem key={client.id} value={client.id.toString()}>
|
||||
{client.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{clients.length === 0 ? (
|
||||
<div className="rounded-md border border-yellow-200 bg-yellow-50 p-3 text-sm text-yellow-800">
|
||||
Nenhum cliente cadastrado.{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="underline font-medium"
|
||||
onClick={() => { setShowNewProjectDialog(false); setShowNewClientDialog(true); }}
|
||||
>
|
||||
Criar cliente
|
||||
</button>{" "}
|
||||
antes de criar um projeto.
|
||||
</div>
|
||||
) : (
|
||||
<Select name="clientId" required>
|
||||
<SelectTrigger data-testid="select-project-client">
|
||||
<SelectValue placeholder="Selecione um cliente" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{clients.map(client => (
|
||||
<SelectItem key={client.id} value={client.id.toString()}>
|
||||
{client.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="project-description">Descrição</Label>
|
||||
|
|
|
|||
|
|
@ -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 { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link } from "wouter";
|
||||
import { BrowserFrame } from "@/components/Browser/BrowserFrame";
|
||||
import {
|
||||
|
|
@ -13,6 +13,8 @@ import { Input } from "@/components/ui/input";
|
|||
import { Badge } from "@/components/ui/badge";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
interface XosStats {
|
||||
total_contacts: number;
|
||||
|
|
@ -66,6 +68,68 @@ interface Activity {
|
|||
|
||||
export default function XosCentral() {
|
||||
const [activeTab, setActiveTab] = useState("dashboard");
|
||||
const [isNewContactOpen, setIsNewContactOpen] = useState(false);
|
||||
const [newContact, setNewContact] = useState({ name: "", email: "", phone: "", company: "", position: "" });
|
||||
const [isNewActivityOpen, setIsNewActivityOpen] = useState(false);
|
||||
const [newActivity, setNewActivity] = useState({ type: "task", title: "", description: "", due_at: "", priority: "normal" });
|
||||
const [isNewSelectorOpen, setIsNewSelectorOpen] = useState(false);
|
||||
const [isNewDealOpen, setIsNewDealOpen] = useState(false);
|
||||
const [newDeal, setNewDeal] = useState({ title: "", pipeline_id: "1", stage_id: "1", value: "" });
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const createContactMutation = useMutation({
|
||||
mutationFn: async (data: typeof newContact) => {
|
||||
const res = await fetch("/api/xos/contacts", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error("Erro ao criar contato");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/xos/contacts"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/xos/stats"] });
|
||||
setIsNewContactOpen(false);
|
||||
setNewContact({ name: "", email: "", phone: "", company: "", position: "" });
|
||||
},
|
||||
});
|
||||
|
||||
const createDealMutation = useMutation({
|
||||
mutationFn: async (data: typeof newDeal) => {
|
||||
const res = await fetch("/api/xos/deals", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error("Erro ao criar negócio");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/xos/deals"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/xos/stats"] });
|
||||
setIsNewDealOpen(false);
|
||||
setNewDeal({ title: "", pipeline_id: "1", stage_id: "1", value: "" });
|
||||
},
|
||||
});
|
||||
|
||||
const createActivityMutation = useMutation({
|
||||
mutationFn: async (data: typeof newActivity) => {
|
||||
const res = await fetch("/api/xos/activities", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error("Erro ao criar atividade");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/xos/activities"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/xos/stats"] });
|
||||
setIsNewActivityOpen(false);
|
||||
setNewActivity({ type: "task", title: "", description: "", due_at: "", priority: "normal" });
|
||||
},
|
||||
});
|
||||
|
||||
const { data: stats } = useQuery<XosStats>({
|
||||
queryKey: ["/api/xos/stats"],
|
||||
|
|
@ -177,7 +241,7 @@ export default function XosCentral() {
|
|||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<Button data-testid="button-new-contact">
|
||||
<Button data-testid="button-new-contact" onClick={() => setIsNewSelectorOpen(true)}>
|
||||
<PlusCircle className="h-4 w-4 mr-2" />
|
||||
Novo
|
||||
</Button>
|
||||
|
|
@ -406,7 +470,7 @@ export default function XosCentral() {
|
|||
<Filter className="h-4 w-4 mr-2" />
|
||||
Filtros
|
||||
</Button>
|
||||
<Button size="sm">
|
||||
<Button size="sm" onClick={() => setIsNewContactOpen(true)}>
|
||||
<PlusCircle className="h-4 w-4 mr-2" />
|
||||
Novo Contato
|
||||
</Button>
|
||||
|
|
@ -478,7 +542,7 @@ export default function XosCentral() {
|
|||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Atividades</CardTitle>
|
||||
<Button size="sm">
|
||||
<Button size="sm" onClick={() => setIsNewActivityOpen(true)}>
|
||||
<PlusCircle className="h-4 w-4 mr-2" />
|
||||
Nova Atividade
|
||||
</Button>
|
||||
|
|
@ -520,6 +584,175 @@ export default function XosCentral() {
|
|||
</Tabs>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Seletor: o que deseja criar? */}
|
||||
<Dialog open={isNewSelectorOpen} onOpenChange={setIsNewSelectorOpen}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>O que deseja criar?</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-3 pt-2">
|
||||
<button
|
||||
className="flex items-center gap-4 p-4 rounded-lg border hover:bg-blue-50 hover:border-blue-300 transition-colors text-left"
|
||||
onClick={() => { setIsNewSelectorOpen(false); setIsNewContactOpen(true); }}
|
||||
>
|
||||
<div className="bg-blue-100 p-2 rounded-lg"><Users className="h-5 w-5 text-blue-600" /></div>
|
||||
<div><p className="font-semibold text-slate-800">Contato</p><p className="text-sm text-slate-500">Adicionar lead ou cliente</p></div>
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-4 p-4 rounded-lg border hover:bg-emerald-50 hover:border-emerald-300 transition-colors text-left"
|
||||
onClick={() => { setIsNewSelectorOpen(false); setIsNewDealOpen(true); }}
|
||||
>
|
||||
<div className="bg-emerald-100 p-2 rounded-lg"><TrendingUp className="h-5 w-5 text-emerald-600" /></div>
|
||||
<div><p className="font-semibold text-slate-800">Negócio</p><p className="text-sm text-slate-500">Criar oportunidade de venda</p></div>
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-4 p-4 rounded-lg border hover:bg-violet-50 hover:border-violet-300 transition-colors text-left"
|
||||
onClick={() => { setIsNewSelectorOpen(false); setIsNewActivityOpen(true); }}
|
||||
>
|
||||
<div className="bg-violet-100 p-2 rounded-lg"><Calendar className="h-5 w-5 text-violet-600" /></div>
|
||||
<div><p className="font-semibold text-slate-800">Atividade</p><p className="text-sm text-slate-500">Agendar tarefa ou reunião</p></div>
|
||||
</button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Dialog Novo Negócio */}
|
||||
<Dialog open={isNewDealOpen} onOpenChange={setIsNewDealOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Novo Negócio</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 pt-2">
|
||||
<div>
|
||||
<Label>Título *</Label>
|
||||
<Input placeholder="Ex: Proposta comercial Empresa X" value={newDeal.title} onChange={(e) => setNewDeal({ ...newDeal, title: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Estágio</Label>
|
||||
<select className="w-full mt-1 border rounded-md px-3 py-2 text-sm" value={newDeal.stage_id} onChange={(e) => setNewDeal({ ...newDeal, stage_id: e.target.value })}>
|
||||
<option value="1">Prospecção</option>
|
||||
<option value="2">Qualificação</option>
|
||||
<option value="3">Proposta</option>
|
||||
<option value="4">Negociação</option>
|
||||
<option value="5">Ganho</option>
|
||||
<option value="6">Perdido</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Valor (R$)</Label>
|
||||
<Input type="number" placeholder="0,00" value={newDeal.value} onChange={(e) => setNewDeal({ ...newDeal, value: e.target.value })} />
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button variant="outline" className="flex-1" onClick={() => setIsNewDealOpen(false)}>Cancelar</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={() => createDealMutation.mutate(newDeal)}
|
||||
disabled={!newDeal.title || createDealMutation.isPending}
|
||||
>
|
||||
{createDealMutation.isPending ? "Salvando..." : "Salvar Negócio"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={isNewContactOpen} onOpenChange={setIsNewContactOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Novo Contato</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 pt-2">
|
||||
<div>
|
||||
<Label>Nome *</Label>
|
||||
<Input placeholder="Nome completo" value={newContact.name} onChange={(e) => setNewContact({ ...newContact, name: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>E-mail</Label>
|
||||
<Input placeholder="email@empresa.com" value={newContact.email} onChange={(e) => setNewContact({ ...newContact, email: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Telefone / WhatsApp</Label>
|
||||
<Input placeholder="(11) 99999-9999" value={newContact.phone} onChange={(e) => setNewContact({ ...newContact, phone: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Empresa</Label>
|
||||
<Input placeholder="Nome da empresa" value={newContact.company} onChange={(e) => setNewContact({ ...newContact, company: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Cargo</Label>
|
||||
<Input placeholder="Cargo ou função" value={newContact.position} onChange={(e) => setNewContact({ ...newContact, position: e.target.value })} />
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button variant="outline" className="flex-1" onClick={() => setIsNewContactOpen(false)}>Cancelar</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={() => createContactMutation.mutate(newContact)}
|
||||
disabled={!newContact.name || createContactMutation.isPending}
|
||||
>
|
||||
{createContactMutation.isPending ? "Salvando..." : "Salvar Contato"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={isNewActivityOpen} onOpenChange={setIsNewActivityOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Nova Atividade</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 pt-2">
|
||||
<div>
|
||||
<Label>Tipo</Label>
|
||||
<select
|
||||
className="w-full mt-1 border rounded-md px-3 py-2 text-sm"
|
||||
value={newActivity.type}
|
||||
onChange={(e) => setNewActivity({ ...newActivity, type: e.target.value })}
|
||||
>
|
||||
<option value="task">Tarefa</option>
|
||||
<option value="call">Ligação</option>
|
||||
<option value="email">E-mail</option>
|
||||
<option value="meeting">Reunião</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Título *</Label>
|
||||
<Input placeholder="Descreva a atividade" value={newActivity.title} onChange={(e) => setNewActivity({ ...newActivity, title: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Descrição</Label>
|
||||
<Input placeholder="Detalhes adicionais" value={newActivity.description} onChange={(e) => setNewActivity({ ...newActivity, description: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Data prevista</Label>
|
||||
<Input type="datetime-local" value={newActivity.due_at} onChange={(e) => setNewActivity({ ...newActivity, due_at: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Prioridade</Label>
|
||||
<select
|
||||
className="w-full mt-1 border rounded-md px-3 py-2 text-sm"
|
||||
value={newActivity.priority}
|
||||
onChange={(e) => setNewActivity({ ...newActivity, priority: e.target.value })}
|
||||
>
|
||||
<option value="low">Baixa</option>
|
||||
<option value="normal">Normal</option>
|
||||
<option value="high">Alta</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button variant="outline" className="flex-1" onClick={() => setIsNewActivityOpen(false)}>Cancelar</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={() => createActivityMutation.mutate(newActivity)}
|
||||
disabled={!newActivity.title || createActivityMutation.isPending}
|
||||
>
|
||||
{createActivityMutation.isPending ? "Salvando..." : "Salvar Atividade"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</BrowserFrame>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,6 +77,15 @@ services:
|
|||
ERPNEXT_URL: ${ERPNEXT_URL:-http://erpnext:8080}
|
||||
ERPNEXT_API_KEY: ${ERPNEXT_API_KEY:-}
|
||||
ERPNEXT_API_SECRET: ${ERPNEXT_API_SECRET:-}
|
||||
# ── MiroFlow (container) ─────────────────────────────────────────────
|
||||
MIROFLOW_HOST: "miroflow"
|
||||
MIROFLOW_PORT: "8006"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:5000/api/health || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
ports:
|
||||
- "5000:5000"
|
||||
depends_on:
|
||||
|
|
@ -195,6 +204,19 @@ services:
|
|||
- arcadia-internal
|
||||
|
||||
|
||||
# ── MiroFlow (agente científico) ─────────────────────────────────────────────
|
||||
miroflow:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.miroflow
|
||||
restart: always
|
||||
environment:
|
||||
MIROFLOW_PORT: "8006"
|
||||
OLLAMA_BASE_URL: "http://ollama-ia1upsekrad96at5hq97e4qa:11434"
|
||||
networks:
|
||||
- arcadia-internal
|
||||
- ollama-net
|
||||
|
||||
# ── Microserviços Python ─────────────────────────────────────────────────────
|
||||
contabil:
|
||||
build:
|
||||
|
|
@ -366,6 +388,11 @@ services:
|
|||
volumes:
|
||||
- ./docker/superset/superset_config.py:/app/pythonpath/superset_config.py:ro
|
||||
- ./docker/superset/init.sh:/app/docker/init.sh:ro
|
||||
- ./docker/superset/images/arcadia_logo.png:/app/superset/static/assets/images/arcadia_logo.png:ro
|
||||
- ./docker/superset/images/arcadia_logo_dark.png:/app/superset/static/assets/images/arcadia_logo_dark.png:ro
|
||||
- ./docker/superset/images/superset-logo-horiz.png:/app/superset/static/assets/images/superset-logo-horiz.png:ro
|
||||
- ./docker/superset/images/favicon.png:/app/superset/static/assets/images/favicon.png:ro
|
||||
- ./docker/superset/images/arcadia_favicon.png:/app/superset/static/assets/images/arcadia_favicon.png:ro
|
||||
- superset_home:/app/superset_home
|
||||
depends_on:
|
||||
db:
|
||||
|
|
@ -389,6 +416,24 @@ services:
|
|||
- "traefik.http.middlewares.superset-https-redirect.redirectscheme.permanent=true"
|
||||
profiles: [bi]
|
||||
|
||||
# ── Neo4j (Knowledge Graph) ─────────────────────────────────────────────────
|
||||
# Ativar com: docker compose --profile kg up
|
||||
neo4j:
|
||||
image: neo4j:5
|
||||
restart: always
|
||||
environment:
|
||||
NEO4J_AUTH: neo4j/${NEO4J_PASSWORD:-arcadia123}
|
||||
NEO4J_PLUGINS: '["apoc"]'
|
||||
volumes:
|
||||
- neo4j_data:/data
|
||||
- neo4j_logs:/logs
|
||||
ports:
|
||||
- "7474:7474"
|
||||
- "7687:7687"
|
||||
networks:
|
||||
- arcadia-internal
|
||||
profiles: [kg]
|
||||
|
||||
networks:
|
||||
arcadia-internal:
|
||||
driver: bridge
|
||||
|
|
@ -403,9 +448,8 @@ volumes:
|
|||
redis_data:
|
||||
ollama_models:
|
||||
open_webui_data:
|
||||
|
||||
|
||||
|
||||
neo4j_data:
|
||||
neo4j_logs:
|
||||
plus_db:
|
||||
plus_storage:
|
||||
superset_home:
|
||||
|
|
|
|||
|
|
@ -63,7 +63,25 @@ services:
|
|||
condition: service_healthy
|
||||
profiles: [bi]
|
||||
|
||||
# ── Neo4j (Knowledge Graph) ─────────────────────────────────────────────────
|
||||
# Ativar com: docker compose --profile kg up
|
||||
neo4j:
|
||||
image: neo4j:5
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NEO4J_AUTH: neo4j/${NEO4J_PASSWORD:-arcadia123}
|
||||
NEO4J_PLUGINS: '["apoc"]'
|
||||
volumes:
|
||||
- neo4j_data:/data
|
||||
- neo4j_logs:/logs
|
||||
ports:
|
||||
- "7474:7474"
|
||||
- "7687:7687"
|
||||
profiles: [kg]
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
uploads:
|
||||
superset_home:
|
||||
neo4j_data:
|
||||
neo4j_logs:
|
||||
|
|
|
|||
|
|
@ -38,8 +38,8 @@ model_list:
|
|||
|
||||
- model_name: arcadia-agent
|
||||
litellm_params:
|
||||
model: ollama/arcadia-agent
|
||||
api_base: os.environ/OLLAMA_BASE_URL
|
||||
model: groq/llama-3.3-70b-versatile
|
||||
api_key: os.environ/GROQ_API_KEY
|
||||
|
||||
- model_name: nomic-embed-text
|
||||
litellm_params:
|
||||
|
|
@ -70,10 +70,10 @@ model_list:
|
|||
# api_key: os.environ/ANTHROPIC_API_KEY
|
||||
|
||||
# ── TIER 3: Groq (opt-in — inferência rápida sem dados persistidos) ──────────
|
||||
# - model_name: groq-llama
|
||||
# litellm_params:
|
||||
# model: groq/llama-3.3-70b-versatile
|
||||
# api_key: os.environ/GROQ_API_KEY
|
||||
- model_name: groq-llama
|
||||
litellm_params:
|
||||
model: groq/llama-3.3-70b-versatile
|
||||
api_key: os.environ/GROQ_API_KEY
|
||||
|
||||
# ── Modelo padrão do Arcádia (Manus usa este) ─────────────────────────────────
|
||||
# Prioridade: DeepSeek R1 14B (TIER 2) → llama3.2 (fallback rápido)
|
||||
|
|
@ -87,9 +87,6 @@ router_settings:
|
|||
fallbacks:
|
||||
- {"gpt-4o": ["arcadia-agent"]}
|
||||
- {"gpt-4o-mini": ["arcadia-agent"]}
|
||||
- {"arcadia-default": ["llama3.2"]}
|
||||
- {"arcadia-agent": ["llama3.2"]}
|
||||
- {"arcadia-finetuned": ["deepseek-r1"]}
|
||||
- {"arcadia-embed": ["nomic-embed-text"]}
|
||||
|
||||
litellm_settings:
|
||||
|
|
|
|||
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.9 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
|
|
@ -46,6 +46,8 @@ SQLLAB_ASYNC_TIME_LIMIT_SEC = 300
|
|||
|
||||
# ── Branding Arcádia ──────────────────────────────────────────────────────────
|
||||
APP_NAME = "Arcádia Insights"
|
||||
APP_ICON = "/static/assets/images/arcadia_logo.png"
|
||||
APP_ICON_WIDTH = 150
|
||||
LOGO_TARGET_PATH = "/"
|
||||
FAVICONS = [{"href": "/static/assets/images/favicon.png"}]
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: "gpt-4o-mini",
|
||||
model: "arcadia-agent",
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: userPrompt }
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ export interface AgentThought {
|
|||
|
||||
export class ManusIntelligence {
|
||||
private static instance: ManusIntelligence;
|
||||
private model = "gpt-4o";
|
||||
private model = "arcadia-agent";
|
||||
private callCount = 0;
|
||||
private tokenCount = 0;
|
||||
private errorCount = 0;
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ function requireAuth(req: Request, res: Response, next: NextFunction) {
|
|||
// Apply auth to all routes
|
||||
router.use(requireAuth);
|
||||
|
||||
// Helper to get tenant ID from request header or user's first tenant
|
||||
// Helper to get tenant ID from request header or user's first tenant (auto-creates if none)
|
||||
async function getTenantId(req: Request): Promise<number | null> {
|
||||
const userId = (req.user as any).id;
|
||||
const headerTenantId = req.headers["x-tenant-id"];
|
||||
|
|
@ -40,8 +40,13 @@ async function getTenantId(req: Request): Promise<number | null> {
|
|||
const isMember = await compassStorage.isUserInTenant(userId, tenantId);
|
||||
return isMember ? tenantId : null;
|
||||
}
|
||||
const tenants = await compassStorage.getUserTenants(userId);
|
||||
return tenants.length > 0 ? tenants[0].id : null;
|
||||
const userTenants = await compassStorage.getUserTenants(userId);
|
||||
if (userTenants.length > 0) return userTenants[0].id;
|
||||
// Auto-cria tenant padrão para o usuário na primeira vez
|
||||
const userName = (req.user as any).name || (req.user as any).username || "Minha Organização";
|
||||
const tenant = await compassStorage.createTenant({ name: userName, plan: "free", status: "active" });
|
||||
await compassStorage.addUserToTenant({ tenantId: tenant.id, userId, role: "owner", isOwner: "true" });
|
||||
return tenant.id;
|
||||
}
|
||||
|
||||
// Validate tenant membership
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { manusService } from "./manus/service";
|
|||
import { db } from "../db/index";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
|
||||
|
||||
const router = Router();
|
||||
|
||||
const DEV_AGENT_CONTEXT = `CONTEXTO ESPECIAL - DEV AGENT:
|
||||
|
|
@ -77,9 +79,7 @@ Usuário: ${message}
|
|||
|
||||
Responda como Dev Agent, criando os recursos solicitados:`;
|
||||
|
||||
const result = await manusService.run(userId, fullPrompt, []);
|
||||
|
||||
const responseContent = result.finalResponse || "Desculpe, não consegui processar sua solicitação.";
|
||||
const responseContent = await manusService.runSync(userId, fullPrompt, []);
|
||||
|
||||
const jsonMatch = responseContent.match(/```json\s*([\s\S]*?)\s*```/);
|
||||
let action = null;
|
||||
|
|
|
|||
|
|
@ -174,7 +174,7 @@ Responda APENAS com o código, sem explicações adicionais.`;
|
|||
: prompt;
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: "gpt-4o",
|
||||
model: "arcadia-agent",
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: userPrompt },
|
||||
|
|
@ -228,7 +228,7 @@ Use português brasileiro.`;
|
|||
: message;
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: "gpt-4o",
|
||||
model: "arcadia-agent",
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: userPrompt },
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import type { Express, Request, Response } from "express";
|
||||
import { manusService } from "./service";
|
||||
import { db } from "../../db/index";
|
||||
import { manusRuns, manusSteps } from "@shared/schema";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
|
||||
export function registerManusRoutes(app: Express): void {
|
||||
const handleManusRun = async (req: Request, res: Response) => {
|
||||
|
|
@ -53,7 +56,7 @@ export function registerManusRoutes(app: Express): void {
|
|||
if (!req.isAuthenticated()) {
|
||||
return res.status(401).json({ error: "Not authenticated" });
|
||||
}
|
||||
|
||||
|
||||
const runs = await manusService.getUserRuns(req.user!.id);
|
||||
res.json(runs);
|
||||
} catch (error) {
|
||||
|
|
@ -61,4 +64,40 @@ export function registerManusRoutes(app: Express): void {
|
|||
res.status(500).json({ error: "Failed to get runs" });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/api/manus/runs/:id", async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!req.isAuthenticated()) {
|
||||
return res.status(401).json({ error: "Not authenticated" });
|
||||
}
|
||||
const runId = parseInt(req.params.id);
|
||||
manusService.cancelRun(runId); // sinaliza o loop para parar
|
||||
await db.delete(manusSteps).where(eq(manusSteps.runId, runId));
|
||||
await db.delete(manusRuns).where(and(eq(manusRuns.id, runId), eq(manusRuns.userId, req.user!.id)));
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Manus delete run error:", error);
|
||||
res.status(500).json({ error: "Failed to delete run" });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/api/manus/runs", async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!req.isAuthenticated()) {
|
||||
return res.status(401).json({ error: "Not authenticated" });
|
||||
}
|
||||
const userRuns = await manusService.getUserRuns(req.user!.id);
|
||||
const runIds = userRuns.map((r: any) => r.id);
|
||||
if (runIds.length > 0) {
|
||||
for (const id of runIds) {
|
||||
await db.delete(manusSteps).where(eq(manusSteps.runId, id));
|
||||
}
|
||||
await db.delete(manusRuns).where(eq(manusRuns.userId, req.user!.id));
|
||||
}
|
||||
res.json({ success: true, deleted: runIds.length });
|
||||
} catch (error) {
|
||||
console.error("Manus delete all runs error:", error);
|
||||
res.status(500).json({ error: "Failed to delete runs" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,26 +14,73 @@ const openai = new OpenAI({
|
|||
baseURL: process.env.AI_INTEGRATIONS_OPENAI_BASE_URL,
|
||||
});
|
||||
|
||||
const SYSTEM_PROMPT = `Você é o Manus, assistente da Arcádia Suite.
|
||||
const SYSTEM_PROMPT = `Você é o Agente Arcádia Manus, assistente empresarial inteligente da Arcádia Suite.
|
||||
|
||||
REGRAS CRÍTICAS:
|
||||
1. Responda APENAS o que foi perguntado. Seja direto e objetivo.
|
||||
2. NUNCA adicione SWOT, PDCA, Canvas, matrizes ou análises não pedidas.
|
||||
3. Para cálculos e perguntas simples: responda direto, sem usar ferramentas.
|
||||
4. Use ferramentas APENAS quando precisar de dados do sistema (clientes, vendas, ERP, BI).
|
||||
5. Responda SEMPRE em JSON no formato abaixo.
|
||||
IDENTIDADE:
|
||||
- Você é o Manus, IA da Arcádia Suite. Se perguntado: "Sou o Manus, agente IA da Arcádia Suite."
|
||||
- Não revele detalhes técnicos da infraestrutura (modelos, APIs, servidores).
|
||||
|
||||
FERRAMENTAS (use 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()}
|
||||
|
||||
FORMATO OBRIGATÓRIO:
|
||||
{"thought": "raciocínio breve", "tool": "finish", "tool_input": {"answer": "resposta direta"}}
|
||||
FORMATO DE RESPOSTA OBRIGATÓRIO (JSON):
|
||||
{"thought": "raciocínio sobre o próximo passo", "tool": "nome_ferramenta", "tool_input": {"param": "valor"}}
|
||||
|
||||
Com ferramenta:
|
||||
{"thought": "raciocínio", "tool": "nome_ferramenta", "tool_input": {"param": "valor"}}`;
|
||||
Para concluir:
|
||||
{"thought": "raciocínio final", "tool": "finish", "tool_input": {"answer": "resposta COMPLETA com dados, tabelas, análise e conclusão"}}
|
||||
|
||||
REGRA CRÍTICA: O campo "answer" deve conter TODO o conteúdo — tabelas, cálculos, insights. NUNCA apenas "relatório gerado".`;
|
||||
|
||||
class ManusService extends EventEmitter {
|
||||
private async executeTool(tool: string, input: Record<string, any>, userId: string): Promise<ToolResult> {
|
||||
private cancelledRuns = new Set<number>();
|
||||
|
||||
cancelRun(runId: number) {
|
||||
this.cancelledRuns.add(runId);
|
||||
setTimeout(() => this.cancelledRuns.delete(runId), 60000); // limpa após 1 min
|
||||
}
|
||||
|
||||
async executeTool(tool: string, input: Record<string, any>, userId: string): Promise<ToolResult> {
|
||||
try {
|
||||
switch (tool) {
|
||||
case "web_search":
|
||||
|
|
@ -2137,6 +2184,19 @@ class ManusService extends EventEmitter {
|
|||
return { runId: run.id };
|
||||
}
|
||||
|
||||
async runSync(userId: string, prompt: string, attachedFiles?: Array<{name: string, content: string, base64?: string}>): Promise<string> {
|
||||
const [run] = await db.insert(manusRuns).values({
|
||||
userId,
|
||||
prompt,
|
||||
status: "running"
|
||||
}).returning();
|
||||
|
||||
await this.executeAgentLoop(run.id, userId, prompt, attachedFiles);
|
||||
|
||||
const [completed] = await db.select().from(manusRuns).where(eq(manusRuns.id, run.id));
|
||||
return completed?.result || "Não foi possível processar a solicitação.";
|
||||
}
|
||||
|
||||
private async executeAgentLoop(runId: number, userId: string, prompt: string, attachedFiles?: Array<{name: string, content: string, base64?: string}>, conversationHistory?: Array<{role: string; content: string}>) {
|
||||
let userPrompt = prompt;
|
||||
if (attachedFiles && attachedFiles.length > 0) {
|
||||
|
|
@ -2169,14 +2229,26 @@ class ManusService extends EventEmitter {
|
|||
|
||||
while (step < maxSteps && !finished) {
|
||||
step++;
|
||||
|
||||
|
||||
// Verificar cancelamento antes de cada step
|
||||
if (this.cancelledRuns.has(runId)) {
|
||||
this.cancelledRuns.delete(runId);
|
||||
await db.update(manusRuns).set({ status: "stopped", completedAt: new Date() }).where(eq(manusRuns.id, runId));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await openai.chat.completions.create({
|
||||
const STEP_TIMEOUT_MS = 60000; // 60s por step
|
||||
const responsePromise = openai.chat.completions.create({
|
||||
model: "arcadia-agent",
|
||||
messages,
|
||||
temperature: 0.2,
|
||||
max_tokens: 1500,
|
||||
});
|
||||
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("Step timeout (60s)")), STEP_TIMEOUT_MS)
|
||||
);
|
||||
const response = await Promise.race([responsePromise, timeoutPromise]);
|
||||
|
||||
const content = response.choices[0]?.message?.content || "";
|
||||
messages.push({ role: "assistant", content });
|
||||
|
|
|
|||
|
|
@ -90,6 +90,8 @@ router.post('/upload', upload.single('file'), async (req, res) => {
|
|||
if (fileName.endsWith('.zip')) sourceType = 'mongodb';
|
||||
else if (fileName.endsWith('.json')) sourceType = 'json';
|
||||
else if (fileName.endsWith('.csv')) sourceType = 'csv';
|
||||
else if (fileName.endsWith('.rar')) sourceType = 'sql-rar';
|
||||
else if (fileName.endsWith('.sql.gz') || fileName.endsWith('.sql')) sourceType = 'sql';
|
||||
|
||||
const [job] = await db.insert(migrationJobs).values({
|
||||
name: name || `Migração ${new Date().toLocaleDateString('pt-BR')}`,
|
||||
|
|
@ -108,26 +110,26 @@ router.post('/upload', upload.single('file'), async (req, res) => {
|
|||
|
||||
await execAsync(`unzip -o "${filePath}" -d "${extractDir}"`);
|
||||
|
||||
const subdirs = fs.readdirSync(extractDir).filter(f =>
|
||||
fs.statSync(path.join(extractDir, f)).isDirectory() &&
|
||||
const subdirs = fs.readdirSync(extractDir).filter(f =>
|
||||
fs.statSync(path.join(extractDir, f)).isDirectory() &&
|
||||
fs.readdirSync(path.join(extractDir, f)).some(sf => sf.endsWith('.bson'))
|
||||
);
|
||||
|
||||
const bsonDir = subdirs.length > 0
|
||||
const bsonDir = subdirs.length > 0
|
||||
? path.join(extractDir, subdirs[0])
|
||||
: extractDir;
|
||||
|
||||
await db.update(migrationJobs)
|
||||
.set({
|
||||
.set({
|
||||
status: 'analyzing',
|
||||
importConfig: { extractPath: bsonDir }
|
||||
})
|
||||
.where(eq(migrationJobs.id, job.id));
|
||||
|
||||
const analysis = analyzeBackupDirectory(bsonDir);
|
||||
|
||||
|
||||
await db.update(migrationJobs)
|
||||
.set({
|
||||
.set({
|
||||
status: 'mapping',
|
||||
totalRecords: analysis.totalRecords,
|
||||
analysisResult: analysis
|
||||
|
|
@ -148,6 +150,87 @@ router.post('/upload', upload.single('file'), async (req, res) => {
|
|||
});
|
||||
}
|
||||
}
|
||||
} else if (sourceType === 'sql-rar') {
|
||||
const extractDir = path.join(UPLOAD_DIR, `job-${job.id}`);
|
||||
fs.mkdirSync(extractDir, { recursive: true });
|
||||
|
||||
await db.update(migrationJobs)
|
||||
.set({ status: 'analyzing' })
|
||||
.where(eq(migrationJobs.id, job.id));
|
||||
|
||||
await execAsync(`7z x "${filePath}" -o"${extractDir}" -y`);
|
||||
|
||||
const allFiles = fs.readdirSync(extractDir);
|
||||
const sqlGzFile = allFiles.find(f => f.endsWith('.sql.gz'));
|
||||
const sqlFile = allFiles.find(f => f.endsWith('.sql'));
|
||||
|
||||
let finalSqlPath: string;
|
||||
if (sqlGzFile) {
|
||||
const gzPath = path.join(extractDir, sqlGzFile);
|
||||
finalSqlPath = gzPath.replace(/\.gz$/, '');
|
||||
await execAsync(`gunzip -f "${gzPath}"`);
|
||||
} else if (sqlFile) {
|
||||
finalSqlPath = path.join(extractDir, sqlFile);
|
||||
} else {
|
||||
throw new Error('Nenhum arquivo .sql ou .sql.gz encontrado dentro do RAR');
|
||||
}
|
||||
|
||||
const sqlContent = fs.readFileSync(finalSqlPath, 'utf8');
|
||||
const tableMatches = sqlContent.match(/CREATE TABLE\s+`?(\w+)`?/gi) || [];
|
||||
const tables = tableMatches.map(m => m.replace(/CREATE TABLE\s+`?/i, '').replace(/`/g, '').trim());
|
||||
const lineCount = sqlContent.split('\n').length;
|
||||
|
||||
await db.update(migrationJobs)
|
||||
.set({
|
||||
status: 'mapping',
|
||||
totalRecords: lineCount,
|
||||
analysisResult: {
|
||||
type: 'sql',
|
||||
sqlPath: finalSqlPath,
|
||||
tables,
|
||||
tableCount: tables.length,
|
||||
lineCount,
|
||||
fileSizeBytes: fs.statSync(finalSqlPath).size
|
||||
},
|
||||
importConfig: { sqlPath: finalSqlPath }
|
||||
})
|
||||
.where(eq(migrationJobs.id, job.id));
|
||||
} else if (sourceType === 'sql') {
|
||||
const extractDir = path.join(UPLOAD_DIR, `job-${job.id}`);
|
||||
fs.mkdirSync(extractDir, { recursive: true });
|
||||
|
||||
await db.update(migrationJobs)
|
||||
.set({ status: 'analyzing' })
|
||||
.where(eq(migrationJobs.id, job.id));
|
||||
|
||||
let finalSqlPath: string;
|
||||
if (fileName.endsWith('.sql.gz')) {
|
||||
finalSqlPath = path.join(extractDir, fileName.replace(/\.gz$/, ''));
|
||||
await execAsync(`gunzip -c "${filePath}" > "${finalSqlPath}"`);
|
||||
} else {
|
||||
finalSqlPath = filePath;
|
||||
}
|
||||
|
||||
const sqlContent = fs.readFileSync(finalSqlPath, 'utf8');
|
||||
const tableMatches = sqlContent.match(/CREATE TABLE\s+`?(\w+)`?/gi) || [];
|
||||
const tables = tableMatches.map(m => m.replace(/CREATE TABLE\s+`?/i, '').replace(/`/g, '').trim());
|
||||
const lineCount = sqlContent.split('\n').length;
|
||||
|
||||
await db.update(migrationJobs)
|
||||
.set({
|
||||
status: 'mapping',
|
||||
totalRecords: lineCount,
|
||||
analysisResult: {
|
||||
type: 'sql',
|
||||
sqlPath: finalSqlPath,
|
||||
tables,
|
||||
tableCount: tables.length,
|
||||
lineCount,
|
||||
fileSizeBytes: fs.statSync(finalSqlPath).size
|
||||
},
|
||||
importConfig: { sqlPath: finalSqlPath }
|
||||
})
|
||||
.where(eq(migrationJobs.id, job.id));
|
||||
}
|
||||
|
||||
const [updatedJob] = await db.select().from(migrationJobs).where(eq(migrationJobs.id, job.id));
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
- Roda localmente com LLM open-source. Se perguntado: "Sou o Manus, IA local da Arcádia Suite."
|
||||
- NÃO é OpenAI, ChatGPT ou nenhum serviço externo.
|
||||
- Você é o Manus, assistente empresarial da Arcádia Suite.
|
||||
- Se perguntado sobre identidade: "Sou o Manus, assistente da Arcádia Suite."
|
||||
- NÃO mencione modelos de linguagem, APIs ou infraestrutura técnica.
|
||||
- NUNCA diga que é uma "IA local" ou que "não tem acesso externo" — você tem capacidades completas.
|
||||
|
||||
REGRAS:
|
||||
- Responda APENAS o que foi perguntado. Seja direto e objetivo.
|
||||
- Para cálculos, perguntas simples e saudações: responda diretamente.
|
||||
- NUNCA adicione SWOT, Canvas, PDCA, matrizes ou frameworks não solicitados.
|
||||
- NUNCA adicione rodapés, citações de fonte ou "Inteligência Arcádia Business" automaticamente.
|
||||
COMPORTAMENTO:
|
||||
- Seja direto e objetivo. Responda exatamente o que foi perguntado.
|
||||
- Para cálculos e perguntas simples: responda diretamente, sem rodeios.
|
||||
- NUNCA adicione SWOT, Canvas, PDCA, matrizes ou frameworks NÃO solicitados.
|
||||
- NUNCA adicione rodapés automáticos ou citações de fonte não pedidas.
|
||||
- Quando não souber algo, diga claramente sem inventar.
|
||||
- Use Markdown para dados tabulares quando fizer sentido.`;
|
||||
- Use Markdown para tabelas e dados estruturados quando fizer sentido.
|
||||
|
||||
CAPACIDADES COMPLETAS DO MANUS:
|
||||
- Responde perguntas gerais, realiza cálculos e análises
|
||||
- Pesquisa na web e sintetiza informações atualizadas
|
||||
- Analisa documentos, planilhas e arquivos anexados
|
||||
- Consulta dados do ERP, CRM, financeiro e base de conhecimento
|
||||
- Gera gráficos, relatórios e dashboards no BI
|
||||
- Executa diagnósticos empresariais (Canvas, SWOT, PDCA) quando SOLICITADO
|
||||
- Comunica-se com agentes especializados da plataforma
|
||||
|
||||
ANÁLISE E DADOS:
|
||||
- Use tabelas Markdown formatadas para dados estruturados.
|
||||
- Calcule variações percentuais e tendências quando relevante.
|
||||
- Forneça insights reais, não apenas dados brutos.`;
|
||||
|
||||
export interface DiagnosticContext {
|
||||
canvas?: any[];
|
||||
|
|
@ -116,3 +134,79 @@ ${diagnosticContext.requirements.map(req =>
|
|||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
export function buildAgentPromptForChat(
|
||||
knowledgeBaseContext: string,
|
||||
fileContent?: string,
|
||||
diagnosticContext?: DiagnosticContext
|
||||
): string {
|
||||
const now = new Date().toLocaleString('pt-BR', { timeZone: 'America/Sao_Paulo', dateStyle: 'full', timeStyle: 'short' });
|
||||
|
||||
let prompt = `Você é o Manus, assistente da Arcádia Suite.
|
||||
|
||||
IDENTIDADE: Manus, assistente da Arcádia Suite.
|
||||
- NUNCA mencione "OnboardBI", modelos de linguagem, APIs ou infraestrutura.
|
||||
- NUNCA diga que não tem acesso a dados — use as ferramentas para buscar.
|
||||
- Se perguntado sobre identidade: "Sou o Manus, assistente da Arcádia Suite."
|
||||
|
||||
DATA/HORA: ${now}
|
||||
|
||||
FERRAMENTAS DISPONÍVEIS:
|
||||
${getToolsDescription()}
|
||||
|
||||
⚠️ REGRA ABSOLUTA: Responda SEMPRE e APENAS em JSON válido. NUNCA escreva texto livre.
|
||||
|
||||
FORMATO (toda resposta deve ser exatamente assim):
|
||||
{"thought": "raciocínio breve", "tool": "nome_ferramenta", "tool_input": {"param": "valor"}}
|
||||
|
||||
Para perguntas simples/cálculos (sem precisar de dados do sistema):
|
||||
{"thought": "resposta direta", "tool": "finish", "tool_input": {"answer": "resposta em Markdown"}}
|
||||
|
||||
QUANDO USAR FERRAMENTAS:
|
||||
- Perguntas sobre dados da empresa, clientes, vendas, financeiro → erp_query
|
||||
- Perguntas sobre BI, tabelas, dashboards → bi_list_tables ou bi_execute_query
|
||||
- Pesquisa de mercado, notícias, tendências → deep_research ou web_search
|
||||
- Base de conhecimento interna → knowledge_query
|
||||
- Análise de documento → analyze_file
|
||||
- Qualquer dúvida sobre o que existe no sistema → use as ferramentas para descobrir
|
||||
|
||||
REGRAS:
|
||||
- NUNCA diga "não tenho acesso" — use as ferramentas.
|
||||
- NUNCA adicione SWOT, Canvas, PDCA sem ser solicitado.
|
||||
- Para análises com dados: use tabelas Markdown e variações percentuais.
|
||||
- Máximo 8 passos.`;
|
||||
|
||||
if (knowledgeBaseContext) {
|
||||
prompt += `\n\n## Base de Conhecimento\nDocumentos relevantes encontrados:\n\n${knowledgeBaseContext}`;
|
||||
}
|
||||
|
||||
if (fileContent) {
|
||||
prompt += `\n\n## Documento Anexado\n${fileContent}`;
|
||||
}
|
||||
|
||||
if (diagnosticContext) {
|
||||
prompt += `\n\n## Contexto Empresarial (Process Compass)`;
|
||||
if (diagnosticContext.projectName) prompt += `\n**Projeto:** ${diagnosticContext.projectName}`;
|
||||
if (diagnosticContext.clientName) prompt += `\n**Cliente:** ${diagnosticContext.clientName}`;
|
||||
|
||||
if (diagnosticContext.canvas?.length) {
|
||||
prompt += `\n\n### Canvas BMC\n${diagnosticContext.canvas.map(b =>
|
||||
`**${b.blockType}**: ${b.content || 'Sem conteúdo'}`).join('\n')}`;
|
||||
}
|
||||
if (diagnosticContext.swot?.analyses?.length) {
|
||||
prompt += `\n\n### SWOT`;
|
||||
diagnosticContext.swot.analyses.forEach(a => {
|
||||
const items = diagnosticContext.swot!.items.filter(i => i.swotAnalysisId === a.id);
|
||||
prompt += `\n**${a.name}**: F:${items.filter(i=>i.type==='strength').length} Fr:${items.filter(i=>i.type==='weakness').length} O:${items.filter(i=>i.type==='opportunity').length} A:${items.filter(i=>i.type==='threat').length}`;
|
||||
});
|
||||
}
|
||||
if (diagnosticContext.pdca?.cycles?.length) {
|
||||
prompt += `\n\n### PDCA\n${diagnosticContext.pdca.cycles.map(c => `**${c.title}** (${c.status})`).join('\n')}`;
|
||||
}
|
||||
if (diagnosticContext.processes?.processes?.length) {
|
||||
prompt += `\n\n### Processos\n${diagnosticContext.processes.processes.map(p => `**${p.name}** (${p.department||'Geral'})`).join('\n')}`;
|
||||
}
|
||||
}
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,33 @@
|
|||
import type { Express, Request, Response } from "express";
|
||||
import OpenAI from "openai";
|
||||
import { chatStorage } from "./storage";
|
||||
import { buildPromptWithContext } from "./prompt";
|
||||
import { buildPromptWithContext, buildAgentPromptForChat } from "./prompt";
|
||||
import { compassStorage } from "../../compass/storage";
|
||||
import { PDFParse } from "pdf-parse";
|
||||
import { learningService } from "../../learning/service";
|
||||
import { manusService } from "../../manus/service";
|
||||
|
||||
const TOOL_LABELS: Record<string, string> = {
|
||||
web_search: "🔍 Pesquisando na web",
|
||||
deep_research: "🔬 Pesquisa profunda",
|
||||
knowledge_query: "📚 Consultando base de conhecimento",
|
||||
erp_query: "🏢 Consultando ERP",
|
||||
bi_execute_query: "📊 Executando consulta BI",
|
||||
bi_create_chart: "📈 Gerando gráfico",
|
||||
bi_create_dashboard: "🗂️ Criando dashboard",
|
||||
bi_list_tables: "📋 Listando tabelas",
|
||||
bi_get_table_columns: "🔎 Verificando colunas",
|
||||
bi_create_dataset: "🗃️ Criando dataset",
|
||||
bi_stats: "📉 Carregando estatísticas BI",
|
||||
semantic_search: "🧠 Buscando conhecimento",
|
||||
learn_url: "📖 Aprendendo URL",
|
||||
web_browse: "🌐 Navegando na web",
|
||||
generate_chart: "📊 Gerando gráfico",
|
||||
analyze_file: "📄 Analisando arquivo",
|
||||
calculate: "🔢 Calculando",
|
||||
list_agents: "🤖 Listando agentes",
|
||||
call_agent: "📡 Comunicando com agente",
|
||||
};
|
||||
|
||||
const openai = new OpenAI({
|
||||
apiKey: process.env.AI_INTEGRATIONS_OPENAI_API_KEY,
|
||||
|
|
@ -258,10 +281,10 @@ export function registerChatRoutes(app: Express): void {
|
|||
}
|
||||
}
|
||||
|
||||
const systemPrompt = buildPromptWithContext(knowledgeContext, processedFileContent || fileContent, diagnosticContext);
|
||||
const systemPrompt = buildAgentPromptForChat(knowledgeContext, processedFileContent || fileContent, diagnosticContext);
|
||||
|
||||
const messages = await chatStorage.getMessagesByConversation(conversationId);
|
||||
const chatMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
const loopMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
...messages.map((m) => ({
|
||||
role: m.role as "user" | "assistant",
|
||||
|
|
@ -273,20 +296,69 @@ export function registerChatRoutes(app: Express): void {
|
|||
res.setHeader("Cache-Control", "no-cache");
|
||||
res.setHeader("Connection", "keep-alive");
|
||||
|
||||
const stream = await openai.chat.completions.create({
|
||||
model: "arcadia-agent",
|
||||
messages: chatMessages,
|
||||
stream: true,
|
||||
max_tokens: 4096,
|
||||
});
|
||||
|
||||
let fullResponse = "";
|
||||
const MAX_STEPS = 8;
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const content = chunk.choices[0]?.delta?.content || "";
|
||||
if (content) {
|
||||
fullResponse += content;
|
||||
res.write(`data: ${JSON.stringify({ content })}\n\n`);
|
||||
for (let step = 0; step < MAX_STEPS; step++) {
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: "arcadia-agent",
|
||||
messages: loopMessages,
|
||||
stream: false,
|
||||
max_tokens: 3000,
|
||||
});
|
||||
|
||||
const rawText = completion.choices[0]?.message?.content || "";
|
||||
loopMessages.push({ role: "assistant", content: rawText });
|
||||
|
||||
// Try to parse as Manus JSON format
|
||||
let parsed: { thought?: string; tool?: string; tool_input?: Record<string, any> } | null = null;
|
||||
try {
|
||||
const jsonMatch = rawText.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) parsed = JSON.parse(jsonMatch[0]);
|
||||
} catch { /* not JSON */ }
|
||||
|
||||
if (parsed?.tool === "finish") {
|
||||
// Stream the final answer
|
||||
const answer = parsed.tool_input?.answer || rawText;
|
||||
fullResponse = answer;
|
||||
// Stream in small chunks for SSE effect
|
||||
const words = answer.split(/(\s+)/);
|
||||
for (const word of words) {
|
||||
res.write(`data: ${JSON.stringify({ content: word })}\n\n`);
|
||||
}
|
||||
break;
|
||||
} else if (parsed?.tool && parsed.tool !== "finish") {
|
||||
// Execute tool and continue loop
|
||||
const label = TOOL_LABELS[parsed.tool] || `⚙️ ${parsed.tool}`;
|
||||
res.write(`data: ${JSON.stringify({ tool_status: label })}\n\n`);
|
||||
|
||||
try {
|
||||
const toolResult = await manusService.executeTool(parsed.tool, parsed.tool_input || {}, userId);
|
||||
loopMessages.push({
|
||||
role: "user",
|
||||
content: `[Resultado de ${parsed.tool}]: ${toolResult.output}`,
|
||||
});
|
||||
} catch (toolErr) {
|
||||
loopMessages.push({
|
||||
role: "user",
|
||||
content: `[Erro em ${parsed.tool}]: Ferramenta falhou, tente outra abordagem.`,
|
||||
});
|
||||
}
|
||||
} else if (step === 0) {
|
||||
// First step returned plain text — force JSON retry
|
||||
loopMessages.push({
|
||||
role: "user",
|
||||
content: `ERRO: Você não respondeu em JSON. Responda APENAS em JSON no formato: {"thought": "...", "tool": "finish", "tool_input": {"answer": "..."}}`,
|
||||
});
|
||||
continue;
|
||||
} else {
|
||||
// Still plain text after retry — stream as-is
|
||||
fullResponse = rawText;
|
||||
const words = rawText.split(/(\s+)/);
|
||||
for (const word of words) {
|
||||
res.write(`data: ${JSON.stringify({ content: word })}\n\n`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,8 +14,10 @@ import { registerManusRoutes } from "./manus/routes";
|
|||
import { registerCustomMcpRoutes } from "./mcp/routes";
|
||||
import { registerAutomationRoutes } from "./automations/routes";
|
||||
import { registerAutomationEngineRoutes } from "./automations/engine-proxy";
|
||||
import { registerSkillRoutes } from "./skills/routes";
|
||||
import { registerBiRoutes } from "./bi/routes";
|
||||
import { registerBiEngineRoutes } from "./bi/engine-proxy";
|
||||
import { registerMiroFlowRoutes } from "./miroflow/engine-proxy";
|
||||
import { registerCommEngineRoutes } from "./communication/proxy";
|
||||
import { registerLearningRoutes } from "./learning/routes";
|
||||
import compassRoutes from "./compass/routes";
|
||||
|
|
@ -68,6 +70,11 @@ export async function registerRoutes(
|
|||
httpServer: Server,
|
||||
app: Express
|
||||
): Promise<Server> {
|
||||
// Health check global — usado pelo Coolify e Docker healthcheck
|
||||
app.get("/api/health", (_req, res) => {
|
||||
res.json({ ok: true, service: "arcadia-suite", ts: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// Auth and session setup first
|
||||
setupAuth(app);
|
||||
|
||||
|
|
@ -93,9 +100,11 @@ export async function registerRoutes(
|
|||
registerCustomMcpRoutes(app);
|
||||
registerAutomationRoutes(app);
|
||||
registerAutomationEngineRoutes(app);
|
||||
registerSkillRoutes(app);
|
||||
registerBiRoutes(app);
|
||||
app.use("/api/graph", graphRoutes);
|
||||
registerBiEngineRoutes(app);
|
||||
registerMiroFlowRoutes(app);
|
||||
registerCommEngineRoutes(app);
|
||||
registerLearningRoutes(app);
|
||||
app.use("/api/compass", compassRoutes);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// Enviar session cookie junto com o Bearer JWT para que Flask-Login identifique
|
||||
// o admin via user_loader (necessário quando o papel Public tem can_read on Dashboard)
|
||||
const dashResp = await fetch(`${SUPERSET_URL}/api/v1/dashboard/${dashboardId}`, {
|
||||
let dashResp = await fetch(`${SUPERSET_URL}/api/v1/dashboard/${dashboardId}`, {
|
||||
headers: { "Authorization": `Bearer ${token}`, "Cookie": session },
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT),
|
||||
});
|
||||
// Token pode ter expirado — limpa cache e tenta uma vez com credenciais frescas
|
||||
if (!dashResp.ok) {
|
||||
clearTokenCache();
|
||||
const fresh = await getServiceToken();
|
||||
dashResp = await fetch(`${SUPERSET_URL}/api/v1/dashboard/${dashboardId}`, {
|
||||
headers: { "Authorization": `Bearer ${fresh.token}`, "Cookie": fresh.session },
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT),
|
||||
});
|
||||
}
|
||||
if (!dashResp.ok) {
|
||||
return res.status(404).json({ error: `Dashboard '${dashboardId}' não encontrado no Superset` });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -237,7 +237,7 @@ Instruções:
|
|||
|
||||
const openai = getOpenAI();
|
||||
const response = await openai.chat.completions.create({
|
||||
model: "gpt-4o",
|
||||
model: "arcadia-agent",
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
...conversationHistory as any,
|
||||
|
|
@ -248,7 +248,7 @@ Instruções:
|
|||
|
||||
const aiContent = response.choices[0]?.message?.content || "Desculpe, não consegui processar sua solicitação.";
|
||||
|
||||
const aiConversation = await supportStorage.createAiResponse(ticket.id, aiContent, "gpt-4o");
|
||||
const aiConversation = await supportStorage.createAiResponse(ticket.id, aiContent, "arcadia-agent");
|
||||
|
||||
res.json(aiConversation);
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -757,7 +757,7 @@ router.post("/projects/:projectId/import-financial", requireAuth, upload.single(
|
|||
const dataPreview = JSON.stringify(rawData.slice(0, 10), null, 2);
|
||||
|
||||
const agentResponse = await openai.chat.completions.create({
|
||||
model: "gpt-4o",
|
||||
model: "arcadia-agent",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
|
|
@ -917,7 +917,7 @@ router.post("/projects/:projectId/checklist/:itemId/agent-assist", requireAuth,
|
|||
: `Empresa: ${project.companyName}\nSetor: ${project.sector}\n\nInformações fornecidas:\n${content}`;
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: "gpt-4o",
|
||||
model: "arcadia-agent",
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: userContent }
|
||||
|
|
@ -1853,7 +1853,7 @@ Para cada item, indique: item (texto), impact (low/medium/high), valuationReleva
|
|||
Responda em JSON: { strengths: [...], weaknesses: [...], opportunities: [...], threats: [...] }`;
|
||||
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: "gpt-4o-mini",
|
||||
model: "arcadia-agent",
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
response_format: { type: "json_object" },
|
||||
});
|
||||
|
|
@ -1997,7 +1997,7 @@ SWOT: ${swot.length} itens
|
|||
Responda de forma consultiva, em português, com foco em recomendações acionáveis.`;
|
||||
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: "gpt-4o-mini",
|
||||
model: "arcadia-agent",
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: message },
|
||||
|
|
@ -2076,7 +2076,7 @@ SWOT: ${swot.length} itens | PDCA: ${pdca.length} ações | Ativos: ${assets.len
|
|||
Gere em formato HTML com seções claras. Use formatação profissional.`;
|
||||
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: "gpt-4o-mini",
|
||||
model: "arcadia-agent",
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ Nome do cliente: ${contactName}`;
|
|||
messages.push({ role: "user", content: message });
|
||||
|
||||
const response = await openai.chat.completions.create({
|
||||
model: "gpt-4o-mini",
|
||||
model: "arcadia-agent",
|
||||
messages,
|
||||
max_tokens: 200,
|
||||
temperature: 0.7,
|
||||
|
|
|
|||
|
|
@ -7429,3 +7429,99 @@ export type SoeEvento = typeof soeEventos.$inferSelect;
|
|||
export type InsertSoeEvento = z.infer<typeof insertSoeEventoSchema>;
|
||||
export type SoeLancamento = typeof soeLancamentos.$inferSelect;
|
||||
export type InsertSoeLancamento = z.infer<typeof insertSoeLancamentoSchema>;
|
||||
|
||||
// =============================================================================
|
||||
// ARCÁDIA AGENTIC SUITE — Skills POO (Fase 1 — 2026-03-24)
|
||||
// Modelo orientado a objetos: herança, composição, polimorfismo, multi-tenant
|
||||
// NÃO remove xosSkillRegistry (modelo legado XOS continua intacto)
|
||||
// =============================================================================
|
||||
|
||||
export const arcadiaSkills = pgTable("arcadia_skills", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
|
||||
// Identidade
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
slug: varchar("slug", { length: 255 }).notNull(),
|
||||
description: text("description"),
|
||||
version: varchar("version", { length: 50 }).notNull().default("1.0.0"),
|
||||
icon: varchar("icon", { length: 100 }),
|
||||
tags: text("tags").array(),
|
||||
|
||||
// Namespace multi-tenant (system > tenant > company > user)
|
||||
namespace: varchar("namespace", { length: 20 }).notNull().default("tenant"), // 'system' | 'tenant' | 'company' | 'user'
|
||||
tenantId: integer("tenant_id").references(() => tenants.id, { onDelete: "cascade" }),
|
||||
companyId: integer("company_id"),
|
||||
userId: varchar("user_id").references(() => users.id, { onDelete: "set null" }),
|
||||
|
||||
// Herança POO — lista de slugs: ['/skill:system/base_report']
|
||||
extends: text("extends").array(),
|
||||
// Interfaces/contratos implementados
|
||||
implements: text("implements").array(),
|
||||
|
||||
// Encapsulamento
|
||||
visibilityExecute: varchar("visibility_execute", { length: 20 }).default("public"), // 'public' | 'private' | 'protected'
|
||||
visibilityParams: varchar("visibility_params", { length: 20 }).default("public"),
|
||||
|
||||
// Composição — dependências como referências /tipo/caminho
|
||||
dependencies: text("dependencies").array(),
|
||||
|
||||
// Trigger (quando executar automaticamente)
|
||||
triggerType: varchar("trigger_type", { length: 30 }), // 'schedule' | 'event' | 'manual' | 'webhook'
|
||||
triggerConfig: jsonb("trigger_config"),
|
||||
|
||||
// Corpo da skill (Markdown com blocos /skill/, /kg/, /tool/, etc.)
|
||||
body: text("body"),
|
||||
|
||||
// Schemas de entrada/saída
|
||||
parametersSchema: jsonb("parameters_schema"),
|
||||
returnSchema: jsonb("return_schema"),
|
||||
|
||||
// Estado
|
||||
status: varchar("status", { length: 20 }).notNull().default("draft"), // 'draft' | 'active' | 'archived'
|
||||
isSystem: boolean("is_system").default(false),
|
||||
|
||||
// Autoria e rastreamento
|
||||
author: varchar("author", { length: 255 }),
|
||||
createdBy: varchar("created_by").references(() => users.id, { onDelete: "set null" }),
|
||||
createdAt: timestamp("created_at").default(sql`CURRENT_TIMESTAMP`).notNull(),
|
||||
updatedAt: timestamp("updated_at").default(sql`CURRENT_TIMESTAMP`).notNull(),
|
||||
});
|
||||
|
||||
export const skillExecutions = pgTable("skill_executions", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
skillId: uuid("skill_id").notNull().references(() => arcadiaSkills.id, { onDelete: "cascade" }),
|
||||
|
||||
// Contexto de execução
|
||||
tenantId: integer("tenant_id").references(() => tenants.id),
|
||||
companyId: integer("company_id"),
|
||||
userId: varchar("user_id").references(() => users.id, { onDelete: "set null" }),
|
||||
|
||||
// Origem da execução
|
||||
triggeredBy: varchar("triggered_by", { length: 30 }), // 'manual' | 'schedule' | 'automation' | 'agent' | 'openclaw'
|
||||
automationId: integer("automation_id"),
|
||||
parentExecutionId: uuid("parent_execution_id"), // para skills compostas
|
||||
|
||||
// Dados
|
||||
inputParams: jsonb("input_params"),
|
||||
outputResult: jsonb("output_result"),
|
||||
resolvedDependencies: jsonb("resolved_dependencies"), // cache das refs / resolvidas
|
||||
|
||||
// Estado
|
||||
status: varchar("status", { length: 20 }).notNull().default("pending"), // 'pending' | 'running' | 'success' | 'error' | 'cancelled'
|
||||
errorMessage: text("error_message"),
|
||||
durationMs: integer("duration_ms"),
|
||||
|
||||
// Imutabilidade / auditoria
|
||||
auditHash: varchar("audit_hash", { length: 64 }),
|
||||
|
||||
startedAt: timestamp("started_at").default(sql`CURRENT_TIMESTAMP`).notNull(),
|
||||
completedAt: timestamp("completed_at"),
|
||||
});
|
||||
|
||||
export const insertArcadiaSkillSchema = createInsertSchema(arcadiaSkills).omit({ id: true, createdAt: true, updatedAt: true });
|
||||
export const insertSkillExecutionSchema = createInsertSchema(skillExecutions).omit({ id: true, startedAt: true });
|
||||
|
||||
export type ArcadiaSkill = typeof arcadiaSkills.$inferSelect;
|
||||
export type InsertArcadiaSkill = z.infer<typeof insertArcadiaSkillSchema>;
|
||||
export type SkillExecution = typeof skillExecutions.$inferSelect;
|
||||
export type InsertSkillExecution = z.infer<typeof insertSkillExecutionSchema>;
|
||||
|
|
|
|||
Loading…
Reference in New Issue