From 783ecd89cb3300c9ca109578623d46e1c36a2ca6 Mon Sep 17 00:00:00 2001 From: Alex Schmitt Date: Tue, 18 Aug 2026 09:22:51 -0300 Subject: [PATCH] feat(canvas): projetos, canvases versionados e artefatos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Novos models Prisma: Project, ProjectFile, ProjectArtifact, ArtifactVersion, Canvas, CanvasVersion (migration 20260818120000) - Rotas /canvas (CRUD + versionamento + share + pin em projeto) - Rotas /projects (CRUD + files + artifacts + context para system prompt) - Conversas suportam projectId (criar/patch + listagem) - Share route /share/[token] resolve canvas e artifact públicos (read-only) - Contratos de tipos Canvas* e Project* em lib/contracts.ts - Message parts suportam referência a canvas (chat-parts + attachments) - proxy.ts protege /projects/* - Deps: @uiw/react-codemirror, @codemirror/lang-*, mermaid, @babel/standalone - 369 testes verdes (canvas-routes + projects-routes cobrem os novos endpoints) --- app/api/share/[token]/route.ts | 66 +- docs/canvas-projects-plan.md | 271 +++++ lib/chat-parts.ts | 23 +- lib/contracts.ts | 71 ++ next-env.d.ts | 1 + package.json | 8 + pnpm-lock.yaml | 930 ++++++++++++++++++ .../migration.sql | 141 +++ prisma/schema.prisma | 108 +- proxy.ts | 2 +- server/app.ts | 4 + server/lib/conversation-attachments.ts | 14 + server/routes/canvas.ts | 365 +++++++ server/routes/conversations.ts | 126 ++- server/routes/projects.ts | 489 +++++++++ server/tests/canvas-routes.test.ts | 501 ++++++++++ server/tests/projects-routes.test.ts | 465 +++++++++ 17 files changed, 3570 insertions(+), 15 deletions(-) create mode 100644 docs/canvas-projects-plan.md create mode 100644 prisma/migrations/20260818120000_canvas_projects_workspace/migration.sql create mode 100644 server/routes/canvas.ts create mode 100644 server/routes/projects.ts create mode 100644 server/tests/canvas-routes.test.ts create mode 100644 server/tests/projects-routes.test.ts diff --git a/app/api/share/[token]/route.ts b/app/api/share/[token]/route.ts index c8bb62b..791f59f 100644 --- a/app/api/share/[token]/route.ts +++ b/app/api/share/[token]/route.ts @@ -23,9 +23,69 @@ export async function GET( }, }); - if (!conversation) { - return Response.json({ error: "Not found" }, { status: 404 }); + if (conversation) { + return Response.json({ type: "conversation", conversation }); } - return Response.json({ conversation }); + const canvas = await prisma.canvas.findFirst({ + where: { shareToken: token }, + select: { + activeVersion: true, + createdAt: true, + id: true, + kind: true, + language: true, + title: true, + }, + }); + + if (canvas) { + const activeVersion = await prisma.canvasVersion.findUnique({ + where: { canvasId_version: { canvasId: canvas.id, version: canvas.activeVersion } }, + select: { content: true }, + }); + + return Response.json({ + type: "canvas", + canvas: { + content: activeVersion?.content ?? "", + createdAt: canvas.createdAt, + kind: canvas.kind, + language: canvas.language, + title: canvas.title, + }, + }); + } + + const artifact = await prisma.projectArtifact.findFirst({ + where: { shareToken: token }, + select: { + currentVersion: true, + id: true, + kind: true, + language: true, + title: true, + updatedAt: true, + }, + }); + + if (artifact) { + const activeVersion = await prisma.artifactVersion.findUnique({ + where: { artifactId_version: { artifactId: artifact.id, version: artifact.currentVersion } }, + select: { content: true }, + }); + + return Response.json({ + type: "artifact", + artifact: { + content: activeVersion?.content ?? "", + kind: artifact.kind, + language: artifact.language, + title: artifact.title, + updatedAt: artifact.updatedAt, + }, + }); + } + + return Response.json({ error: "Not found" }, { status: 404 }); } diff --git a/docs/canvas-projects-plan.md b/docs/canvas-projects-plan.md new file mode 100644 index 0000000..fe790ae --- /dev/null +++ b/docs/canvas-projects-plan.md @@ -0,0 +1,271 @@ +# Plano: Canvas + Projetos no ModelHub (spec congelada v2) + +> Este documento é a fonte única de verdade para a implementação. +> Ajustes da v2 (validação contra codebase): detecção de canvas é **client-side**; +> injeção de contexto em **dois** pontos (server + browser); `/projects` entra no +> matcher do `proxy.ts`; share segue o padrão existente `/share/[token]`; +> transpile React é **client-side** (Babel standalone + iframe), não esbuild-wasm. + +## Decisões congeladas + +- Canvas nasce **dentro da conversa**, auto-detectado durante o stream (client-side). +- Kinds: `markdown | code | html | react | mermaid`. +- Versionamento: toda mudança de conteúdo cria nova `CanvasVersion` (auto-version). +- Fixar ("pin") canvas em projeto = **snapshot imutável** (`ProjectArtifact` + versões). + Editar o canvas NÃO altera o artefato; ação explícita "atualizar do canvas" cria nova versão. +- Projetos: flat (1 nível). Deletar projeto **deleta** suas conversas (Cascade — paridade Claude). +- Share de canvas/artefato via token na URL pública existente `/share/[token]` (read-only). +- Instruções do projeto + arquivos de conhecimento são injetados no system context de TODAS + as conversas do projeto (server para gateway; client para providers browser-session). +- Iteração com IA (MVP): com o painel de canvas aberto, o request inclui o conteúdo atual do + canvas como contexto; se o detector disparar de novo, o canvas ATIVO recebe nova versão + (em vez de criar outro). +- Gaps declarados (não-MVP): RAG real (embeddings) para arquivos de projeto; "remix" público. + +## Modelo de dados (Prisma) + +```prisma +model Project { + id String @id @default(cuid()) + userId String + name String + description String? + instructions String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + conversations Conversation[] + files ProjectFile[] + artifacts ProjectArtifact[] + + @@index([userId, updatedAt]) +} + +model ProjectFile { + id String @id @default(cuid()) + projectId String + fileName String + mimeType String + byteSize Int + blob Bytes + extractedText String? + createdAt DateTime @default(now()) + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@index([projectId]) +} + +model ProjectArtifact { + id String @id @default(cuid()) + projectId String + sourceConversationId String? + sourceCanvasId String? + title String + kind String // CanvasKind + language String? + currentVersion Int @default(1) + shareToken String? @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + conversation Conversation? @relation("ArtifactSourceConversation", fields: [sourceConversationId], references: [id], onDelete: SetNull) + sourceCanvas Canvas? @relation("ArtifactSourceCanvas", fields: [sourceCanvasId], references: [id], onDelete: SetNull) + versions ArtifactVersion[] + + @@index([projectId, updatedAt]) +} + +model ArtifactVersion { + id String @id @default(cuid()) + artifactId String + version Int + content String + createdAt DateTime @default(now()) + + artifact ProjectArtifact @relation(fields: [artifactId], references: [id], onDelete: Cascade) + + @@unique([artifactId, version]) +} + +model Canvas { + id String @id @default(cuid()) + conversationId String + title String + kind String // CanvasKind + language String? + content String + activeVersion Int @default(1) + shareToken String? @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) + versions CanvasVersion[] + pinnedArtifacts ProjectArtifact[] @relation("ArtifactSourceCanvas") + + @@index([conversationId, updatedAt]) +} + +model CanvasVersion { + id String @id @default(cuid()) + canvasId String + version Int + kind String + language String? + content String + createdAt DateTime @default(now()) + + canvas Canvas @relation(fields: [canvasId], references: [id], onDelete: Cascade) + + @@unique([canvasId, version]) +} +``` + +`Conversation` ganha: +```prisma + projectId String? + project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade) +``` + +## Contratos (lib/contracts.ts — CONGELADO, não editar durante as lanes) + +Tipos `CanvasKind`, `CanvasSummary`, `CanvasDetail`, `ProjectSummary`, `ProjectDetail`, +`ProjectFileSummary`, `ProjectArtifactSummary`, `ProjectArtifactDetail`, +`ProjectContextPayload` + `MODELHUB_PROJECT_HEADER = "x-modelhub-project-id"`. + +## Endpoints + +### Conversations (extensões em server/routes/conversations.ts — aditivo) +- `POST /conversations` body aceita `projectId?` (valida posse do projeto). +- `PATCH /conversations/:id` body aceita `projectId?: string | null` (mover/retirar). +- `GET /conversations/:id/canvases` → `{ canvases: CanvasSummary[] }` +- `POST /conversations/:id/canvases` `{ title?, kind, language?, content }` → `201 { canvas: CanvasDetail }` + +### Canvas (server/routes/canvas.ts, basePath `/canvas`, auth igual conversations) +- `GET /canvas/:id` → `{ canvas: CanvasDetail }` (content = versão ativa; versions = índice) +- `PATCH /canvas/:id` `{ title?, kind?, language?, content? }` → se `content` mudou, cria + `CanvasVersion` nova (`version = max+1`) e atualiza `activeVersion`. Retorna CanvasDetail. +- `DELETE /canvas/:id` +- `GET /canvas/:id/versions` → `{ versions: [{version, kind, language, createdAt}] }` +- `GET /canvas/:id/versions/:version` → `{ version: {..., content} }` +- `POST /canvas/:id/versions/:version/restore` → conteúdo da versão vira versão nova (nova = ativa) +- `POST /canvas/:id/share` → `{ shareToken }` (idempotente); `DELETE /canvas/:id/share` +- `POST /canvas/:id/pin` `{ projectId }` → cria `ProjectArtifact` (+`ArtifactVersion` v1, + snapshot do content atual); se já existe artefato com `sourceCanvasId = id` no projeto, + retorna conflito `409 { artifactId }`. → `201 { artifact: ProjectArtifactDetail }` +- Ownership: resolver canvas → conversation → `userId` (findFirst com userId). + +### Projects (server/routes/projects.ts, basePath `/projects`, auth igual conversations) +- `GET /projects` → `{ projects: ProjectSummary[] }` (com counts) +- `POST /projects` `{ name (1..100), description?, instructions? }` → `201` +- `GET /projects/:id` → `{ project: ProjectDetail }` +- `PATCH /projects/:id` `{ name?, description?, instructions? }` +- `DELETE /projects/:id` (cascata: conversas, arquivos, artefatos) +- `GET /projects/:id/conversations` → conversas do projeto (shape do GET /conversations) +- `POST /projects/:id/files` (multipart `file`; mesma validação de + `server/lib/conversation-attachments.ts` — tipo, tamanho máx, extração de texto p/ docs) + → `201 { file: ProjectFileSummary }` +- `GET /projects/:id/files` → `{ files: ProjectFileSummary[] }` +- `GET /projects/:id/files/:fileId/content` → binário autenticado (inline) +- `DELETE /projects/:id/files/:fileId` +- `GET /projects/:id/artifacts` → `{ artifacts: ProjectArtifactSummary[] }` +- `GET /projects/:id/artifacts/:artifactId` → `{ artifact: ProjectArtifactDetail }` +- `DELETE /projects/:id/artifacts/:artifactId` +- `POST /projects/:id/artifacts/:artifactId/refresh` `{ canvasId }` → nova + `ArtifactVersion` snapshot do canvas atual (o canvas deve ter `sourceCanvasId` = este + artefato? Não — o artefato deve ter `sourceCanvasId === canvasId`). `409` se não bater. +- `GET /projects/:id/context` → `{ ProjectContextPayload }` — instructions + + `knowledge[]` (fileName + texto extraído, cap por arquivo 20k chars, total 60k chars). + +### Share público (app/api/share/[token]/route.ts — estender, manter runtime nodejs) +`GET /api/share/:token` resolve na ordem: conversation.shareToken → canvas.shareToken → +artifact.shareToken (versão atual). Sempre inclui `type`: +- `{ type: "conversation", conversation: {...} }` (shape atual + type) +- `{ type: "canvas", canvas: { title, kind, language, content, createdAt } }` +- `{ type: "artifact", artifact: { title, kind, language, content, updatedAt } }` + +### Message part "canvas" (lib/chat-parts.ts — extensão ADITIVA) +Novo part: `{ type: "canvas", canvasId, title, kind }`. `parseSingleMessagePart` aceita; +`hydrateMessageParts` repassa sem transformação. Renderização no chat = card clicável. + +## Detecção de canvas (client-side — lib/canvas-detector.ts) + +Acumulador incremental sobre `onTextDelta`: +- Fenced block com linguagem `mermaid` (qualquer tamanho ≥ 40 chars) → kind `mermaid`. +- Fenced block ≥ 20 linhas OU ≥ 800 chars, linguagem `html` → `html`; `tsx|jsx` → `react`; + outra linguagem → `code`. +- Mensagem total (sem fences elegíveis) ≥ 1500 chars → `markdown` (conteúdo = mensagem toda). +- Título heurístico: primeira heading `# ` ou 1ª linha não-vazia truncada a 60 chars. +- Emite UMA sugestão por stream (a mais forte; mermaid > react/html > code > markdown). +- Se já existe canvas ATIVO na conversa (painel aberto): o disparo gera **update** (PATCH nova + versão) do canvas ativo, não um canvas novo. +- Bubble da mensagem: quando canvas é criado a partir de fence, o bubble mostra só o texto + fora do fence + card do canvas (part `canvas`). Caso `markdown`, bubble mostra 1º parágrafo + card. + +## Iteração com IA (MVP) + +Com o painel do canvas aberto, o request do chat inclui, antes da mensagem do usuário, +uma mensagem de contexto (role user, prefixada): `"[canvas ativo: ]\n<content>"` +(cap 20k chars). Toggle na toolbar (default ligado). Providers browser-session (Puter) +usam o mesmo mecanismo client-side. + +## Injeção de contexto de projeto (wave 2) + +- Server: em `server/lib/provider-core.ts` (`buildUserContextMessage`), ler header + `x-modelhub-project-id`; se presente e do usuário, anexar `Project instructions:\n...` + e o knowledge (mesmos caps de `/projects/:id/context`) ao system context existente. +- Browser: em `lib/browser-chat-providers.ts` (`buildBrowserSystemPrompt`), quando o chat + informar `projectId`, buscar `/projects/:id/context` e anexar igual. +- Chat envia o header em `/v1/chat/completions` e nas rotas `/:provider/api/chat`. + +## Preview (client-side) + +- `html`: iframe sandboxed (`sandbox="allow-scripts"`) com srcdoc. +- `react`: pai transpila com `@babel/standalone` (`presets: ["react", "typescript"]`); + iframe com React+ReactDOM UMD (CDN esm.sh/unpkg) + código transpilado. Erros → overlay no preview. +- `mermaid`: `import("mermaid")` dinâmico, render SVG no container (sem iframe), tema dark-aware. +- `markdown`: `MarkdownRenderer` existente. +- `code`: CodeMirror read-only com highlight da linguagem. + +## UX (resumo) + +- Desktop: chat | canvas split-pane redimensionável; canvas abre automaticamente durante o + stream e atualiza ao vivo. Mobile: canvas como overlay/sheet fullscreen. +- Toolbar: título editável, kind badge, copiar, baixar, versões (dialog: listar, ver, restaurar), + compartilhar (gera/copiar link `/share/<token>`), fixar em projeto (picker), toggle contexto IA. +- Sidebar do chat: seção "Projetos" (lista + ver todos → `/projects`). +- `/projects`: grid de cards + criar (nome, descrição, instruções). `/projects/[id]`: + cabeçalho editável + seções Conversas / Arquivos (upload, download, delete) / Artefatos + (abrir, versões, atualizar do canvas, compartilhar, excluir). "Nova conversa no projeto" + → `/chat?project=<id>&new=1`. +- Strings de UI em pt-BR. Nenhum dep novo além dos já instalados. + +## Dependências (já instaladas no wave 0 — NÃO modificar package.json nas lanes) + +`@uiw/react-codemirror`, `@codemirror/lang-markdown|javascript|html|css`, `mermaid`, +`@babel/standalone` (+ `-D @types/babel__standalone`). + +## Ownership de escrita por lane (estrito) + +- **fixer-backend**: `prisma/schema.prisma`, `server/routes/projects.ts`, + `server/routes/canvas.ts`, `server/routes/conversations.ts` (aditivo), + `server/app.ts` (mount), `server/tests/projects-routes.test.ts`, + `server/tests/canvas-routes.test.ts`, `lib/chat-parts.ts` (aditivo), + `app/api/share/[token]/route.ts`. Rodar `pnpm prisma:generate` (migration best-effort). +- **designer-canvas**: `components/canvas/**`, `lib/canvas-detector.ts`, + `lib/canvas-client.ts`, `components/chat/chat-page.tsx`, `app/share/[token]/page.tsx`. +- **designer-projects**: `app/(app)/projects/**`, `components/projects/**`, + `components/chat/chat-history-sidebar.tsx`. +- Congelados (leitura apenas): `lib/contracts.ts`, `docs/canvas-projects-plan.md`, + `package.json`, `proxy.ts` (já atualizado com `/projects/:path*`). + +## Fases + +1. Wave 1 (paralelo): backend + canvas UI + projects UI. +2. Wave 2: injeção de contexto (provider-core + browser-chat-providers) + wiring + `?project=` no chat (criar conversa no projeto, header, seletor no composer). +3. Wave 3: verificação integrada (lint, typecheck, test, build) + correções. +4. Pós-MVP (fora de escopo agora): RAG p/ arquivos, remix público, export PDF/PNG. diff --git a/lib/chat-parts.ts b/lib/chat-parts.ts index 4f197de..8ecea90 100644 --- a/lib/chat-parts.ts +++ b/lib/chat-parts.ts @@ -32,7 +32,19 @@ type MetaPart = { type: "meta"; }; -export type ConversationMessagePart = AttachmentReferencePart | MetaPart | TextPart; +type CanvasReferencePart = { + canvasId: string; + /** CanvasKind: markdown | code | html | react | mermaid */ + kind: string; + title: string; + type: "canvas"; +}; + +export type ConversationMessagePart = + | AttachmentReferencePart + | CanvasReferencePart + | MetaPart + | TextPart; export type ConversationAttachmentDescriptor = { byteSize: number; @@ -46,7 +58,11 @@ export type ConversationAttachmentDescriptor = { export type HydratedAttachmentPart = AttachmentReferencePart & ConversationAttachmentDescriptor; -export type HydratedConversationMessagePart = HydratedAttachmentPart | MetaPart | TextPart; +export type HydratedConversationMessagePart = + | CanvasReferencePart + | HydratedAttachmentPart + | MetaPart + | TextPart; export function createMessageContentFallback( parts: readonly ConversationMessagePart[], @@ -59,6 +75,9 @@ export function createMessageContentFallback( if (part.type === "meta") { return ""; } + if (part.type === "canvas") { + return `[canvas] ${part.title}`; + } return `[${part.kind}] ${part.fileName}`; }) diff --git a/lib/contracts.ts b/lib/contracts.ts index 6c8de2d..c431821 100644 --- a/lib/contracts.ts +++ b/lib/contracts.ts @@ -316,3 +316,74 @@ export const MODELHUB_MODELS_ATTEMPTED_HEADER = */ export const MODELHUB_FALLBACK_DIAGNOSTIC_HEADER = "x-modelhub-fallback-diagnostic" as const + +/** Projeto ativo enviado pelo chat para injeção de contexto (instruções + knowledge). */ +export const MODELHUB_PROJECT_HEADER = "x-modelhub-project-id" as const + +export type CanvasKind = "markdown" | "code" | "html" | "react" | "mermaid" + +export type CanvasSummary = { + id: string + conversationId: string + title: string + kind: CanvasKind + language: string | null + activeVersion: number + shareToken: string | null + createdAt: string + updatedAt: string +} + +export type CanvasDetail = CanvasSummary & { + content: string + versions: Array<{ + version: number + kind: CanvasKind + language: string | null + createdAt: string + }> +} + +export type ProjectSummary = { + id: string + name: string + description: string | null + counts: { conversations: number; files: number; artifacts: number } + createdAt: string + updatedAt: string +} + +export type ProjectDetail = ProjectSummary & { + instructions: string | null +} + +export type ProjectFileSummary = { + id: string + fileName: string + mimeType: string + byteSize: number + createdAt: string +} + +export type ProjectArtifactSummary = { + id: string + projectId: string + title: string + kind: CanvasKind + language: string | null + currentVersion: number + sourceConversationId: string | null + sourceCanvasId: string | null + shareToken: string | null + updatedAt: string +} + +export type ProjectArtifactDetail = ProjectArtifactSummary & { + content: string + versions: Array<{ version: number; createdAt: string }> +} + +export type ProjectContextPayload = { + instructions: string | null + knowledge: Array<{ fileName: string; text: string }> +} diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c..ce4e94a 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,7 @@ /// <reference types="next" /> /// <reference types="next/image-types/global" /> import "./.next/types/routes.d.ts"; +import "./.next/types/root-params.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/package.json b/package.json index 45f9754..8820c5a 100644 --- a/package.json +++ b/package.json @@ -57,11 +57,17 @@ "vercel:env:sync": "node scripts/vercel-env-sync.mjs --pull" }, "dependencies": { + "@babel/standalone": "^8.0.4", + "@codemirror/lang-css": "^6.3.1", + "@codemirror/lang-html": "^6.4.12", + "@codemirror/lang-javascript": "^6.2.5", + "@codemirror/lang-markdown": "^6.5.2", "@hono/zod-validator": "^0.9.0", "@neondatabase/auth": "0.2.0-beta.1", "@neondatabase/serverless": "^1.1.0", "@prisma/adapter-neon": "^7.9.1", "@prisma/client": "^7.9.1", + "@uiw/react-codemirror": "^4.25.11", "@vercel/analytics": "^2.0.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -71,6 +77,7 @@ "jszip": "^3.10.1", "katex": "^0.18.4", "lucide-react": "^1.29.0", + "mermaid": "^11.16.1", "next": "16.3.1", "next-themes": "^0.4.6", "pdfjs-dist": "^6.1.200", @@ -91,6 +98,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4.3.3", + "@types/babel__standalone": "^7.1.9", "@types/jsdom": "^30.0.0", "@types/node": "^26.2.0", "@types/react": "^19.2.18", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b3cd35..e0eb917 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,21 @@ importers: .: dependencies: + '@babel/standalone': + specifier: ^8.0.4 + version: 8.0.4 + '@codemirror/lang-css': + specifier: ^6.3.1 + version: 6.3.1 + '@codemirror/lang-html': + specifier: ^6.4.12 + version: 6.4.12 + '@codemirror/lang-javascript': + specifier: ^6.2.5 + version: 6.2.5 + '@codemirror/lang-markdown': + specifier: ^6.5.2 + version: 6.5.2 '@hono/zod-validator': specifier: ^0.9.0 version: 0.9.0(hono@4.13.2)(zod@4.4.3) @@ -42,6 +57,9 @@ importers: '@prisma/client': specifier: ^7.9.1 version: 7.9.1(prisma@7.9.1(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(better-sqlite3@12.8.0)(magicast@0.5.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3))(typescript@5.9.3) + '@uiw/react-codemirror': + specifier: ^4.25.11 + version: 4.25.11(@babel/runtime@7.29.2)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.1)(@codemirror/state@6.7.1)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.43.9)(codemirror@6.0.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@vercel/analytics': specifier: ^2.0.1 version: 2.0.1(next@16.3.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8) @@ -69,6 +87,9 @@ importers: lucide-react: specifier: ^1.29.0 version: 1.31.0(react@19.2.8) + mermaid: + specifier: ^11.16.1 + version: 11.16.1 next: specifier: 16.3.1 version: 16.3.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -124,6 +145,9 @@ importers: '@tailwindcss/postcss': specifier: ^4.3.3 version: 4.3.3 + '@types/babel__standalone': + specifier: ^7.1.9 + version: 7.1.9 '@types/jsdom': specifier: ^30.0.0 version: 30.0.0 @@ -176,6 +200,9 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@antfu/install-pkg@1.1.0': + resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@asamuzakjp/css-color@6.0.5': resolution: {integrity: sha512-mbhpPMmnw/kwW19aRNmSUl1QzLbdGo1SCuE49BT98MNwqF6zaHb3o2owssFc/PEO/4t2UjqtCNwocuDtJornzA==} engines: {node: ^22.13.0 || >=24.0.0} @@ -310,6 +337,10 @@ packages: resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} + '@babel/standalone@8.0.4': + resolution: {integrity: sha512-z8WgyJCfEl7qGAfJJksICOL5zQPpfwa6Yew+91Txf1k3xuDtlDdDe5GX2QdIhgx0HD7oYOCLoF8Y6CwNdawJwQ==} + engines: {node: ^22.18.0 || >=24.11.0} + '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} @@ -418,6 +449,9 @@ packages: '@better-fetch/fetch@1.3.1': resolution: {integrity: sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g==} + '@braintree/sanitize-url@7.1.2': + resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + '@bramus/specificity@2.4.2': resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} hasBin: true @@ -431,6 +465,45 @@ packages: '@captchafox/types@1.4.0': resolution: {integrity: sha512-4xnPMICLinsXghw6zWEF436lhMsBmLxui2QmU7xAEz/+572BRdvc518Sz5OoofbJ7GZG6QPz/wOtJoN8BfKyCg==} + '@chevrotain/types@11.1.2': + resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} + + '@codemirror/autocomplete@6.20.3': + resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==} + + '@codemirror/commands@6.11.0': + resolution: {integrity: sha512-/K4Rl5BN0OtTiPWmJCdqODu38XnDMsDxKY5rgrPnCkutPTJf2wVbkoixLfealF5Kwse/s8P8M5jAiURiwSwnFA==} + + '@codemirror/lang-css@6.3.1': + resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==} + + '@codemirror/lang-html@6.4.12': + resolution: {integrity: sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w==} + + '@codemirror/lang-javascript@6.2.5': + resolution: {integrity: sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==} + + '@codemirror/lang-markdown@6.5.2': + resolution: {integrity: sha512-AwBOdkWYuA//WcM0xO5PfHPUcmz/O2i5o0Nsg1U69SII/loCJlFI1Romd9xp2HYb1kYJRGZotyqRghuHH5n8Kw==} + + '@codemirror/language@6.12.4': + resolution: {integrity: sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==} + + '@codemirror/lint@6.9.7': + resolution: {integrity: sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==} + + '@codemirror/search@6.7.1': + resolution: {integrity: sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==} + + '@codemirror/state@6.7.1': + resolution: {integrity: sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==} + + '@codemirror/theme-one-dark@6.1.3': + resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==} + + '@codemirror/view@6.43.9': + resolution: {integrity: sha512-sTuUzTpPMFebRhg6dawChoKKgndIwfjmJgKVxBefPElcU2NwQ6AFroupk0SFqEerQyZOGRfDNnSN8Dw/lMAsXw==} + '@csstools/color-helpers@6.1.0': resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} engines: {node: '>=20.19.0'} @@ -817,6 +890,12 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@3.1.4': + resolution: {integrity: sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==} + '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -1049,12 +1128,39 @@ packages: '@levischuck/tiny-cbor@0.2.11': resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==} + '@lezer/common@1.5.2': + resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==} + + '@lezer/css@1.3.6': + resolution: {integrity: sha512-YJE78Wcg+zX8f10hiHWQ4Az48Qr/c13eId0VtRQYLBpxHDmDeSrXIlkbl+fJGW42rWC/uoUco9mhBZeVWP/A1g==} + + '@lezer/highlight@1.2.3': + resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==} + + '@lezer/html@1.3.13': + resolution: {integrity: sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==} + + '@lezer/javascript@1.5.4': + resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==} + + '@lezer/lr@1.4.10': + resolution: {integrity: sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==} + + '@lezer/markdown@1.7.2': + resolution: {integrity: sha512-iTkYvoVcKt3WkeL7qUDyXHONZEwLio4wj8KTNi2dnjQEXBZKMV63BpQrPqfsM+OkvuRbiSTAcycYAsQzLhRNoQ==} + + '@marijn/find-cluster-break@1.0.3': + resolution: {integrity: sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==} + '@marsidev/react-turnstile@1.5.0': resolution: {integrity: sha512-Ph6mcj8u9WBDsBO7s9jKPsyRDz1sBPBJwrk+Ngx09vFInvKsQ6U6kW5amEcGq4dHOreB6DgFrOJk7/fy318YlQ==} peerDependencies: react: ^17.0.2 || ^18.0.0 || ^19.0 react-dom: ^17.0.2 || ^18.0.0 || ^19.0 + '@mermaid-js/parser@1.2.0': + resolution: {integrity: sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==} + '@modelcontextprotocol/sdk@1.29.0': resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} engines: {node: '>=18'} @@ -3028,6 +3134,21 @@ packages: '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__standalone@7.1.9': + resolution: {integrity: sha512-IcCNPLqpevUD7UpV8QB0uwQPOyoOKACFf0YtYWRHcmxcakaje4Q7dbG2+jMqxw/I8Zk0NHvEps66WwS7z/UaaA==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -3037,24 +3158,54 @@ packages: '@types/d3-array@3.2.2': resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + '@types/d3-axis@3.0.6': + resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} + + '@types/d3-brush@3.0.6': + resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} + + '@types/d3-chord@3.0.6': + resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} + '@types/d3-color@3.1.0': resolution: {integrity: sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA==} '@types/d3-color@3.1.3': resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + '@types/d3-contour@3.0.6': + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} + '@types/d3-delaunay@6.0.1': resolution: {integrity: sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==} + '@types/d3-dispatch@3.0.7': + resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-dsv@3.0.7': + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} + '@types/d3-ease@3.0.2': resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + '@types/d3-fetch@3.0.7': + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + '@types/d3-format@3.0.1': resolution: {integrity: sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==} '@types/d3-geo@3.1.0': resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + '@types/d3-interpolate@3.0.1': resolution: {integrity: sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==} @@ -3064,12 +3215,27 @@ packages: '@types/d3-path@3.1.1': resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + '@types/d3-polygon@3.0.2': + resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} + + '@types/d3-quadtree@3.0.6': + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} + + '@types/d3-random@3.0.4': + resolution: {integrity: sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==} + + '@types/d3-scale-chromatic@3.1.0': + resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} + '@types/d3-scale@4.0.2': resolution: {integrity: sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA==} '@types/d3-scale@4.0.9': resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + '@types/d3-shape@3.1.7': resolution: {integrity: sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==} @@ -3088,6 +3254,15 @@ packages: '@types/d3-timer@3.0.2': resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + + '@types/d3@7.4.3': + resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} @@ -3147,6 +3322,9 @@ packages: '@types/tough-cookie@4.0.5': resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -3218,6 +3396,28 @@ packages: resolution: {integrity: sha512-U3gxVaDVnuZKhSspW/MzMxE1kq7zOdc072FcSNoqA1I9p8HyKbBFfEHoWckBAMgNMph4MamwS5iTVzFmrnt8TQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@uiw/codemirror-extensions-basic-setup@4.25.11': + resolution: {integrity: sha512-otyFa+n9IOYtEjaKOxPedHkj15fTPUF21wdR9pv0GpZPfuGl27cvmcv6+tognbRu9VvEcsHKE+ESoszeo3KfTw==} + peerDependencies: + '@codemirror/autocomplete': '>=6.0.0' + '@codemirror/commands': '>=6.0.0' + '@codemirror/language': '>=6.0.0' + '@codemirror/lint': '>=6.0.0' + '@codemirror/search': '>=6.0.0' + '@codemirror/state': '>=6.0.0' + '@codemirror/view': '>=6.0.0' + + '@uiw/react-codemirror@4.25.11': + resolution: {integrity: sha512-DYVFAKLX+F/4JS9N/7xexh+TICrlncwkX9HKKInrP1bwO0tSfc3k0GB6oawTYhelVKh20cX3TuRx+NJSkVXuMw==} + peerDependencies: + '@babel/runtime': '>=7.11.0' + '@codemirror/state': '>=6.0.0' + '@codemirror/theme-one-dark': '>=6.0.0' + '@codemirror/view': '>=6.0.0' + codemirror: '>=6.0.0' + react: '>=17.0.0' + react-dom: '>=17.0.0' + '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} deprecated: Potential CWE-502 - Update to 1.3.1 or higher @@ -3330,6 +3530,9 @@ packages: cpu: [x64] os: [win32] + '@upsetjs/venn.js@2.0.0': + resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} + '@vercel/analytics@2.0.1': resolution: {integrity: sha512-MTQG6V9qQrt1tsDeF+2Uoo5aPjqbVPys1xvnIftXSJYG2SrwXRHnqEvVoYID7BTruDz4lCd2Z7rM1BdkUehk2g==} peerDependencies: @@ -3808,6 +4011,9 @@ packages: code-block-writer@13.0.3: resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} + codemirror@6.0.2: + resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -3829,6 +4035,10 @@ packages: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + commander@8.3.0: resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} engines: {node: '>= 12'} @@ -3876,6 +4086,12 @@ packages: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} + cose-base@1.0.3: + resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} + + cose-base@2.2.0: + resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + cosmiconfig@9.0.1: resolution: {integrity: sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==} engines: {node: '>=14'} @@ -3885,6 +4101,9 @@ packages: typescript: optional: true + crelt@1.0.7: + resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -3901,6 +4120,23 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + cytoscape-cose-bilkent@4.1.0: + resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape-fcose@2.2.0: + resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape@3.34.1: + resolution: {integrity: sha512-Lr0RvH9H75y9ar8h9Toy6u4lxRSCcxUq+hHcQ26sVWo6BnaQp1gwEZOYqwuYTZhyW7npyKnNLP8oJ2p1/3OZ7g==} + engines: {node: '>=0.10'} + + d3-array@2.12.1: + resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==} + d3-array@3.2.1: resolution: {integrity: sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ==} engines: {node: '>=12'} @@ -3909,18 +4145,55 @@ packages: resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} engines: {node: '>=12'} + d3-axis@3.0.0: + resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} + engines: {node: '>=12'} + + d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} + engines: {node: '>=12'} + + d3-chord@3.0.1: + resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} + engines: {node: '>=12'} + d3-color@3.1.0: resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} engines: {node: '>=12'} + d3-contour@4.0.2: + resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} + engines: {node: '>=12'} + d3-delaunay@6.0.2: resolution: {integrity: sha512-IMLNldruDQScrcfT+MWnazhHbDJhcRJyOEBAJfwQnHle1RPh6WDuLvxNArUju2VSMSUuKlY5BGHRJ2cYyoFLQQ==} engines: {node: '>=12'} + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + d3-ease@3.0.1: resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} engines: {node: '>=12'} + d3-fetch@3.0.1: + resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + d3-format@3.1.0: resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==} engines: {node: '>=12'} @@ -3933,18 +4206,51 @@ packages: resolution: {integrity: sha512-JEo5HxXDdDYXCaWdwLRt79y7giK8SbhZJbFWXqbRTolCHFI5jRqteLzCsq51NKbUoX0PjBVSohxrx+NoOUujYA==} engines: {node: '>=12'} + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + d3-interpolate@3.0.1: resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} engines: {node: '>=12'} + d3-path@1.0.9: + resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} + d3-path@3.1.0: resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} engines: {node: '>=12'} + d3-polygon@3.0.1: + resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} + engines: {node: '>=12'} + + d3-sankey@0.12.3: + resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + d3-scale@4.0.2: resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} engines: {node: '>=12'} + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-shape@1.3.7: + resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==} + d3-shape@3.2.0: resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} engines: {node: '>=12'} @@ -3961,6 +4267,23 @@ packages: resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} engines: {node: '>=12'} + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + d3@7.9.0: + resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} + engines: {node: '>=12'} + + dagre-d3-es@7.0.14: + resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==} + damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} @@ -3980,6 +4303,9 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} + dayjs@1.11.23: + resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==} + debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: @@ -4108,6 +4434,9 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} + dompurify@3.4.13: + resolution: {integrity: sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==} + domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} @@ -4648,6 +4977,9 @@ packages: resolution: {integrity: sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + hachure-fill@0.5.2: + resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + has-bigints@1.1.0: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} @@ -4758,6 +5090,10 @@ packages: resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + iconv-lite@0.7.3: resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} engines: {node: '>=0.10.0'} @@ -4783,6 +5119,9 @@ packages: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -4806,6 +5145,9 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + internmap@1.0.1: + resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} + internmap@2.0.3: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} @@ -5120,6 +5462,9 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + khroma@2.1.0: + resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} + kleur@3.0.3: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} @@ -5139,6 +5484,12 @@ packages: resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} engines: {node: '>=0.10'} + layout-base@1.0.2: + resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} + + layout-base@2.0.1: + resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} + leac@0.6.0: resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==} @@ -5234,6 +5585,9 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -5296,6 +5650,11 @@ packages: engines: {node: '>= 18'} hasBin: true + marked@16.4.2: + resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} + engines: {node: '>= 20'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -5366,6 +5725,9 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + mermaid@11.16.1: + resolution: {integrity: sha512-TQsq6u22fAn3rek5VOubrhKPo1g5hwC3FXUN9hiyupTckcYiGuuKGkNQrKYwGJkXUxZdojwRG46gsSCFZMDp4g==} + micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} @@ -5685,6 +6047,9 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + package-manager-detector@1.8.0: + resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==} + pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} @@ -5719,6 +6084,9 @@ packages: path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-data-parser@0.1.0: + resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -5805,6 +6173,12 @@ packages: pkg-types@2.3.1: resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + points-on-curve@0.2.0: + resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} + + points-on-path@0.2.1: + resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -6165,6 +6539,9 @@ packages: rou3@0.7.12: resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==} + roughjs@4.6.6: + resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -6176,6 +6553,9 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + safe-array-concat@1.1.4: resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} engines: {node: '>=0.4'} @@ -6455,6 +6835,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + style-mod@4.1.3: + resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} + style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} @@ -6474,6 +6857,9 @@ packages: babel-plugin-macros: optional: true + stylis@4.4.0: + resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} + superjson@2.2.6: resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} engines: {node: '>=16'} @@ -6563,6 +6949,10 @@ packages: peerDependencies: typescript: '>=4.8.4' + ts-dedent@2.3.0: + resolution: {integrity: sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==} + engines: {node: '>=6.10'} + ts-morph@26.0.0: resolution: {integrity: sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==} @@ -6856,6 +7246,9 @@ packages: jsdom: optional: true + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + w3c-xmlserializer@5.0.0: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} @@ -7031,6 +7424,11 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@antfu/install-pkg@1.1.0': + dependencies: + package-manager-detector: 1.8.0 + tinyexec: 1.1.1 + '@asamuzakjp/css-color@6.0.5': dependencies: '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) @@ -7215,6 +7613,8 @@ snapshots: '@babel/runtime@7.29.2': {} + '@babel/standalone@8.0.4': {} + '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 @@ -7310,6 +7710,8 @@ snapshots: '@better-fetch/fetch@1.3.1': {} + '@braintree/sanitize-url@7.1.2': {} + '@bramus/specificity@2.4.2': dependencies: css-tree: 3.2.1 @@ -7322,6 +7724,101 @@ snapshots: '@captchafox/types@1.4.0': {} + '@chevrotain/types@11.1.2': {} + + '@codemirror/autocomplete@6.20.3': + dependencies: + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.9 + '@lezer/common': 1.5.2 + + '@codemirror/commands@6.11.0': + dependencies: + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.9 + '@lezer/common': 1.5.2 + + '@codemirror/lang-css@6.3.1': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@lezer/common': 1.5.2 + '@lezer/css': 1.3.6 + + '@codemirror/lang-html@6.4.12': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/lang-css': 6.3.1 + '@codemirror/lang-javascript': 6.2.5 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.9 + '@lezer/common': 1.5.2 + '@lezer/css': 1.3.6 + '@lezer/html': 1.3.13 + + '@codemirror/lang-javascript@6.2.5': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/language': 6.12.4 + '@codemirror/lint': 6.9.7 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.9 + '@lezer/common': 1.5.2 + '@lezer/javascript': 1.5.4 + + '@codemirror/lang-markdown@6.5.2': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/lang-html': 6.4.12 + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.9 + '@lezer/common': 1.5.2 + '@lezer/markdown': 1.7.2 + + '@codemirror/language@6.12.4': + dependencies: + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.9 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + style-mod: 4.1.3 + + '@codemirror/lint@6.9.7': + dependencies: + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.9 + crelt: 1.0.7 + + '@codemirror/search@6.7.1': + dependencies: + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.9 + crelt: 1.0.7 + + '@codemirror/state@6.7.1': + dependencies: + '@marijn/find-cluster-break': 1.0.3 + + '@codemirror/theme-one-dark@6.1.3': + dependencies: + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.9 + '@lezer/highlight': 1.2.3 + + '@codemirror/view@6.43.9': + dependencies: + '@codemirror/state': 6.7.1 + crelt: 1.0.7 + style-mod: 4.1.3 + w3c-keyname: 2.2.8 + '@csstools/color-helpers@6.1.0': {} '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': @@ -7637,6 +8134,14 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@iconify/types@2.0.0': {} + + '@iconify/utils@3.1.4': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@iconify/types': 2.0.0 + import-meta-resolve: 4.2.0 + '@img/colour@1.1.0': optional: true @@ -7819,11 +8324,50 @@ snapshots: '@levischuck/tiny-cbor@0.2.11': {} + '@lezer/common@1.5.2': {} + + '@lezer/css@1.3.6': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/highlight@1.2.3': + dependencies: + '@lezer/common': 1.5.2 + + '@lezer/html@1.3.13': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/javascript@1.5.4': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + + '@lezer/lr@1.4.10': + dependencies: + '@lezer/common': 1.5.2 + + '@lezer/markdown@1.7.2': + dependencies: + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + + '@marijn/find-cluster-break@1.0.3': {} + '@marsidev/react-turnstile@1.5.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: react: 19.2.8 react-dom: 19.2.8(react@19.2.8) + '@mermaid-js/parser@1.2.0': + dependencies: + '@chevrotain/types': 11.1.2 + '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': dependencies: '@hono/node-server': 1.19.13(hono@4.13.2) @@ -9822,6 +10366,36 @@ snapshots: tslib: 2.8.1 optional: true + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/babel__standalone@7.1.9': + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + '@types/babel__core': 7.20.5 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.0 + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -9831,20 +10405,51 @@ snapshots: '@types/d3-array@3.2.2': {} + '@types/d3-axis@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-brush@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-chord@3.0.6': {} + '@types/d3-color@3.1.0': {} '@types/d3-color@3.1.3': {} + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.2 + '@types/geojson': 7946.0.16 + '@types/d3-delaunay@6.0.1': {} + '@types/d3-dispatch@3.0.7': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-dsv@3.0.7': {} + '@types/d3-ease@3.0.2': {} + '@types/d3-fetch@3.0.7': + dependencies: + '@types/d3-dsv': 3.0.7 + + '@types/d3-force@3.0.10': {} + '@types/d3-format@3.0.1': {} '@types/d3-geo@3.1.0': dependencies: '@types/geojson': 7946.0.16 + '@types/d3-hierarchy@3.1.7': {} + '@types/d3-interpolate@3.0.1': dependencies: '@types/d3-color': 3.1.0 @@ -9855,6 +10460,14 @@ snapshots: '@types/d3-path@3.1.1': {} + '@types/d3-polygon@3.0.2': {} + + '@types/d3-quadtree@3.0.6': {} + + '@types/d3-random@3.0.4': {} + + '@types/d3-scale-chromatic@3.1.0': {} + '@types/d3-scale@4.0.2': dependencies: '@types/d3-time': 3.0.0 @@ -9863,6 +10476,8 @@ snapshots: dependencies: '@types/d3-time': 3.0.4 + '@types/d3-selection@3.0.11': {} + '@types/d3-shape@3.1.7': dependencies: '@types/d3-path': 3.1.1 @@ -9879,6 +10494,48 @@ snapshots: '@types/d3-timer@3.0.2': {} + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + + '@types/d3@7.4.3': + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-color': 3.1.3 + '@types/d3-contour': 3.0.6 + '@types/d3-delaunay': 6.0.1 + '@types/d3-dispatch': 3.0.7 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.1 + '@types/d3-geo': 3.1.0 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-scale-chromatic': 3.1.0 + '@types/d3-selection': 3.0.11 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-time-format': 2.1.0 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 @@ -9940,6 +10597,9 @@ snapshots: '@types/tough-cookie@4.0.5': {} + '@types/trusted-types@2.0.7': + optional: true + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} @@ -10039,6 +10699,33 @@ snapshots: '@typescript-eslint/types': 8.59.4 eslint-visitor-keys: 5.0.1 + '@uiw/codemirror-extensions-basic-setup@4.25.11(@codemirror/autocomplete@6.20.3)(@codemirror/commands@6.11.0)(@codemirror/language@6.12.4)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.1)(@codemirror/state@6.7.1)(@codemirror/view@6.43.9)': + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/commands': 6.11.0 + '@codemirror/language': 6.12.4 + '@codemirror/lint': 6.9.7 + '@codemirror/search': 6.7.1 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.9 + + '@uiw/react-codemirror@4.25.11(@babel/runtime@7.29.2)(@codemirror/autocomplete@6.20.3)(@codemirror/language@6.12.4)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.1)(@codemirror/state@6.7.1)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.43.9)(codemirror@6.0.2)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@babel/runtime': 7.29.2 + '@codemirror/commands': 6.11.0 + '@codemirror/state': 6.7.1 + '@codemirror/theme-one-dark': 6.1.3 + '@codemirror/view': 6.43.9 + '@uiw/codemirror-extensions-basic-setup': 4.25.11(@codemirror/autocomplete@6.20.3)(@codemirror/commands@6.11.0)(@codemirror/language@6.12.4)(@codemirror/lint@6.9.7)(@codemirror/search@6.7.1)(@codemirror/state@6.7.1)(@codemirror/view@6.43.9) + codemirror: 6.0.2 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + transitivePeerDependencies: + - '@codemirror/autocomplete' + - '@codemirror/language' + - '@codemirror/lint' + - '@codemirror/search' + '@ungap/structured-clone@1.3.0': {} '@unrs/resolver-binding-android-arm-eabi@1.12.0': @@ -10105,6 +10792,11 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.12.0': optional: true + '@upsetjs/venn.js@2.0.0': + optionalDependencies: + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + '@vercel/analytics@2.0.1(next@16.3.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8))(react@19.2.8)': optionalDependencies: next: 16.3.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.1)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -10630,6 +11322,16 @@ snapshots: code-block-writer@13.0.3: {} + codemirror@6.0.2: + dependencies: + '@codemirror/autocomplete': 6.20.3 + '@codemirror/commands': 6.11.0 + '@codemirror/language': 6.12.4 + '@codemirror/lint': 6.9.7 + '@codemirror/search': 6.7.1 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.9 + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -10644,6 +11346,8 @@ snapshots: commander@14.0.3: {} + commander@7.2.0: {} + commander@8.3.0: {} concat-map@0.0.1: {} @@ -10676,6 +11380,14 @@ snapshots: object-assign: 4.1.1 vary: 1.1.2 + cose-base@1.0.3: + dependencies: + layout-base: 1.0.2 + + cose-base@2.2.0: + dependencies: + layout-base: 2.0.1 + cosmiconfig@9.0.1(typescript@5.9.3): dependencies: env-paths: 2.2.1 @@ -10685,6 +11397,8 @@ snapshots: optionalDependencies: typescript: 5.9.3 + crelt@1.0.7: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -10700,6 +11414,22 @@ snapshots: csstype@3.2.3: {} + cytoscape-cose-bilkent@4.1.0(cytoscape@3.34.1): + dependencies: + cose-base: 1.0.3 + cytoscape: 3.34.1 + + cytoscape-fcose@2.2.0(cytoscape@3.34.1): + dependencies: + cose-base: 2.2.0 + cytoscape: 3.34.1 + + cytoscape@3.34.1: {} + + d3-array@2.12.1: + dependencies: + internmap: 1.0.1 + d3-array@3.2.1: dependencies: internmap: 2.0.3 @@ -10708,14 +11438,55 @@ snapshots: dependencies: internmap: 2.0.3 + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + d3-color@3.1.0: {} + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-delaunay@6.0.2: dependencies: delaunator: 5.1.0 + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + d3-ease@3.0.1: {} + d3-fetch@3.0.1: + dependencies: + d3-dsv: 3.0.1 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + d3-format@3.1.0: {} d3-format@3.1.2: {} @@ -10724,12 +11495,32 @@ snapshots: dependencies: d3-array: 3.2.4 + d3-hierarchy@3.1.2: {} + d3-interpolate@3.0.1: dependencies: d3-color: 3.1.0 + d3-path@1.0.9: {} + d3-path@3.1.0: {} + d3-polygon@3.0.1: {} + + d3-quadtree@3.0.1: {} + + d3-random@3.0.1: {} + + d3-sankey@0.12.3: + dependencies: + d3-array: 2.12.1 + d3-shape: 1.3.7 + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + d3-scale@4.0.2: dependencies: d3-array: 3.2.4 @@ -10738,6 +11529,12 @@ snapshots: d3-time: 3.1.0 d3-time-format: 4.1.0 + d3-selection@3.0.0: {} + + d3-shape@1.3.7: + dependencies: + d3-path: 1.0.9 + d3-shape@3.2.0: dependencies: d3-path: 3.1.0 @@ -10752,6 +11549,61 @@ snapshots: d3-timer@3.0.1: {} + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3@7.9.0: + dependencies: + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.2 + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-format: 3.1.2 + d3-geo: 3.1.0 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-polygon: 3.0.1 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + + dagre-d3-es@7.0.14: + dependencies: + d3: 7.9.0 + lodash-es: 4.18.1 + damerau-levenshtein@1.0.8: {} data-urls@7.0.0(@noble/hashes@2.2.0): @@ -10779,6 +11631,8 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 + dayjs@1.11.23: {} + debug@3.2.7: dependencies: ms: 2.1.3 @@ -10876,6 +11730,10 @@ snapshots: dependencies: domelementtype: 2.3.0 + dompurify@3.4.13: + optionalDependencies: + '@types/trusted-types': 2.0.7 + domutils@3.2.2: dependencies: dom-serializer: 2.0.0 @@ -11613,6 +12471,8 @@ snapshots: graphql@16.14.2: optional: true + hachure-fill@0.5.2: {} + has-bigints@1.1.0: {} has-flag@4.0.0: {} @@ -11774,6 +12634,10 @@ snapshots: human-signals@8.0.1: {} + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + iconv-lite@0.7.3: dependencies: safer-buffer: 2.1.2 @@ -11794,6 +12658,8 @@ snapshots: parent-module: 1.0.1 resolve-from: 4.0.0 + import-meta-resolve@4.2.0: {} + imurmurhash@0.1.4: {} inherits@2.0.4: {} @@ -11814,6 +12680,8 @@ snapshots: hasown: 2.0.3 side-channel: 1.1.0 + internmap@1.0.1: {} + internmap@2.0.3: {} ip-address@10.2.0: {} @@ -12111,6 +12979,8 @@ snapshots: dependencies: json-buffer: 3.0.1 + khroma@2.1.0: {} + kleur@3.0.3: {} kleur@4.1.5: {} @@ -12123,6 +12993,10 @@ snapshots: dependencies: language-subtag-registry: 0.3.23 + layout-base@1.0.2: {} + + layout-base@2.0.1: {} + leac@0.6.0: {} levn@0.4.1: @@ -12191,6 +13065,8 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash-es@4.18.1: {} + lodash.merge@4.6.2: {} lodash@4.18.1: {} @@ -12248,6 +13124,8 @@ snapshots: marked@15.0.12: {} + marked@16.4.2: {} + math-intrinsics@1.1.0: {} mdast-util-find-and-replace@3.0.2: @@ -12425,6 +13303,30 @@ snapshots: merge2@1.4.1: {} + mermaid@11.16.1: + dependencies: + '@braintree/sanitize-url': 7.1.2 + '@iconify/utils': 3.1.4 + '@mermaid-js/parser': 1.2.0 + '@types/d3': 7.4.3 + '@upsetjs/venn.js': 2.0.0 + cytoscape: 3.34.1 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.34.1) + cytoscape-fcose: 2.2.0(cytoscape@3.34.1) + d3: 7.9.0 + d3-sankey: 0.12.3 + dagre-d3-es: 7.0.14 + dayjs: 1.11.23 + dompurify: 3.4.13 + es-toolkit: 1.50.0 + katex: 0.16.47 + khroma: 2.1.0 + marked: 16.4.2 + roughjs: 4.6.6 + stylis: 4.4.0 + ts-dedent: 2.3.0 + uuid: 11.1.1 + micromark-core-commonmark@2.0.3: dependencies: decode-named-character-reference: 1.3.0 @@ -12890,6 +13792,8 @@ snapshots: dependencies: p-limit: 3.1.0 + package-manager-detector@1.8.0: {} + pako@1.0.11: {} parent-module@1.0.1: @@ -12932,6 +13836,8 @@ snapshots: path-browserify@1.0.1: {} + path-data-parser@0.1.0: {} + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -13011,6 +13917,13 @@ snapshots: exsolve: 1.1.1 pathe: 2.0.3 + points-on-curve@0.2.0: {} + + points-on-path@0.2.1: + dependencies: + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + possible-typed-array-names@1.1.0: {} postcss-selector-parser@7.1.1: @@ -13543,6 +14456,13 @@ snapshots: rou3@0.7.12: {} + roughjs@4.6.6: + dependencies: + hachure-fill: 0.5.2 + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + points-on-path: 0.2.1 + router@2.2.0: dependencies: debug: 4.4.3 @@ -13559,6 +14479,8 @@ snapshots: dependencies: queue-microtask: 1.2.3 + rw@1.3.3: {} + safe-array-concat@1.1.4: dependencies: call-bind: 1.0.9 @@ -13944,6 +14866,8 @@ snapshots: strip-json-comments@3.1.1: {} + style-mod@4.1.3: {} + style-to-js@1.1.21: dependencies: style-to-object: 1.0.14 @@ -13959,6 +14883,8 @@ snapshots: optionalDependencies: '@babel/core': 7.29.0 + stylis@4.4.0: {} + superjson@2.2.6: dependencies: copy-anything: 4.0.5 @@ -14038,6 +14964,8 @@ snapshots: dependencies: typescript: 5.9.3 + ts-dedent@2.3.0: {} + ts-morph@26.0.0: dependencies: '@ts-morph/common': 0.27.0 @@ -14368,6 +15296,8 @@ snapshots: transitivePeerDependencies: - msw + w3c-keyname@2.2.8: {} + w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 diff --git a/prisma/migrations/20260818120000_canvas_projects_workspace/migration.sql b/prisma/migrations/20260818120000_canvas_projects_workspace/migration.sql new file mode 100644 index 0000000..682d173 --- /dev/null +++ b/prisma/migrations/20260818120000_canvas_projects_workspace/migration.sql @@ -0,0 +1,141 @@ +-- AlterTable +ALTER TABLE "Conversation" ADD COLUMN "projectId" TEXT; + +-- CreateTable +CREATE TABLE "Project" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "instructions" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Project_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ProjectFile" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "fileName" TEXT NOT NULL, + "mimeType" TEXT NOT NULL, + "byteSize" INTEGER NOT NULL, + "blob" BYTEA NOT NULL, + "extractedText" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ProjectFile_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ProjectArtifact" ( + "id" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "sourceConversationId" TEXT, + "sourceCanvasId" TEXT, + "title" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "language" TEXT, + "currentVersion" INTEGER NOT NULL DEFAULT 1, + "shareToken" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ProjectArtifact_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ArtifactVersion" ( + "id" TEXT NOT NULL, + "artifactId" TEXT NOT NULL, + "version" INTEGER NOT NULL, + "content" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ArtifactVersion_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Canvas" ( + "id" TEXT NOT NULL, + "conversationId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "kind" TEXT NOT NULL, + "language" TEXT, + "content" TEXT NOT NULL, + "activeVersion" INTEGER NOT NULL DEFAULT 1, + "shareToken" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Canvas_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "CanvasVersion" ( + "id" TEXT NOT NULL, + "canvasId" TEXT NOT NULL, + "version" INTEGER NOT NULL, + "kind" TEXT NOT NULL, + "language" TEXT, + "content" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "CanvasVersion_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Project_userId_updatedAt_idx" ON "Project"("userId", "updatedAt"); + +-- CreateIndex +CREATE INDEX "ProjectFile_projectId_idx" ON "ProjectFile"("projectId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ProjectArtifact_shareToken_key" ON "ProjectArtifact"("shareToken"); + +-- CreateIndex +CREATE INDEX "ProjectArtifact_projectId_updatedAt_idx" ON "ProjectArtifact"("projectId", "updatedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "ArtifactVersion_artifactId_version_key" ON "ArtifactVersion"("artifactId", "version"); + +-- CreateIndex +CREATE UNIQUE INDEX "Canvas_shareToken_key" ON "Canvas"("shareToken"); + +-- CreateIndex +CREATE INDEX "Canvas_conversationId_updatedAt_idx" ON "Canvas"("conversationId", "updatedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "CanvasVersion_canvasId_version_key" ON "CanvasVersion"("canvasId", "version"); + +-- CreateIndex +CREATE INDEX "Conversation_projectId_updatedAt_idx" ON "Conversation"("projectId", "updatedAt"); + +-- AddForeignKey +ALTER TABLE "Project" ADD CONSTRAINT "Project_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProjectFile" ADD CONSTRAINT "ProjectFile_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProjectArtifact" ADD CONSTRAINT "ProjectArtifact_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProjectArtifact" ADD CONSTRAINT "ProjectArtifact_sourceConversationId_fkey" FOREIGN KEY ("sourceConversationId") REFERENCES "Conversation"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ProjectArtifact" ADD CONSTRAINT "ProjectArtifact_sourceCanvasId_fkey" FOREIGN KEY ("sourceCanvasId") REFERENCES "Canvas"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ArtifactVersion" ADD CONSTRAINT "ArtifactVersion_artifactId_fkey" FOREIGN KEY ("artifactId") REFERENCES "ProjectArtifact"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Canvas" ADD CONSTRAINT "Canvas_conversationId_fkey" FOREIGN KEY ("conversationId") REFERENCES "Conversation"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "CanvasVersion" ADD CONSTRAINT "CanvasVersion_canvasId_fkey" FOREIGN KEY ("canvasId") REFERENCES "Canvas"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Conversation" ADD CONSTRAINT "Conversation_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c0cc5db..9de820d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -22,12 +22,111 @@ model User { cloudDeployments CloudDeployment[] usageLogs UsageLog[] conversations Conversation[] + projects Project[] settings UserSettings? memories UserMemory[] routingConfig RoutingConfig? budget UserBudget? } +model Project { + id String @id @default(cuid()) + userId String + name String + description String? + instructions String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + conversations Conversation[] + files ProjectFile[] + artifacts ProjectArtifact[] + + @@index([userId, updatedAt]) +} + +model ProjectFile { + id String @id @default(cuid()) + projectId String + fileName String + mimeType String + byteSize Int + blob Bytes + extractedText String? + createdAt DateTime @default(now()) + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + @@index([projectId]) +} + +model ProjectArtifact { + id String @id @default(cuid()) + projectId String + sourceConversationId String? + sourceCanvasId String? + title String + kind String // CanvasKind: markdown | code | html | react | mermaid + language String? + currentVersion Int @default(1) + shareToken String? @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + conversation Conversation? @relation("ArtifactSourceConversation", fields: [sourceConversationId], references: [id], onDelete: SetNull) + sourceCanvas Canvas? @relation("ArtifactSourceCanvas", fields: [sourceCanvasId], references: [id], onDelete: SetNull) + versions ArtifactVersion[] + + @@index([projectId, updatedAt]) +} + +model ArtifactVersion { + id String @id @default(cuid()) + artifactId String + version Int + content String + createdAt DateTime @default(now()) + + artifact ProjectArtifact @relation(fields: [artifactId], references: [id], onDelete: Cascade) + + @@unique([artifactId, version]) +} + +model Canvas { + id String @id @default(cuid()) + conversationId String + title String + kind String // CanvasKind: markdown | code | html | react | mermaid + language String? + content String + activeVersion Int @default(1) + shareToken String? @unique + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade) + versions CanvasVersion[] + pinnedArtifacts ProjectArtifact[] @relation("ArtifactSourceCanvas") + + @@index([conversationId, updatedAt]) +} + +model CanvasVersion { + id String @id @default(cuid()) + canvasId String + version Int + kind String + language String? + content String + createdAt DateTime @default(now()) + + canvas Canvas @relation(fields: [canvasId], references: [id], onDelete: Cascade) + + @@unique([canvasId, version]) +} + model ApiKey { id String @id @default(cuid()) key String @unique @@ -145,16 +244,21 @@ model Conversation { title String @default("Nova conversa") providerId String? modelId String? + projectId String? archived Boolean @default(false) shareToken String? @unique createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - messages Message[] + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade) + messages Message[] attachments ConversationAttachment[] + canvases Canvas[] + artifacts ProjectArtifact[] @relation("ArtifactSourceConversation") @@index([userId, updatedAt]) + @@index([projectId, updatedAt]) } model Message { diff --git a/proxy.ts b/proxy.ts index b5e4d68..581c37d 100644 --- a/proxy.ts +++ b/proxy.ts @@ -16,5 +16,5 @@ export default function proxy(request: NextRequest) { } export const config = { - matcher: ["/account/:path*", "/chat", "/cloud", "/dashboard/:path*", "/playground", "/setup"], + matcher: ["/account/:path*", "/chat", "/cloud", "/dashboard/:path*", "/playground", "/projects/:path*", "/setup"], }; diff --git a/server/app.ts b/server/app.ts index 7c4097f..44c1ca6 100644 --- a/server/app.ts +++ b/server/app.ts @@ -19,7 +19,9 @@ import { securityHeaders, } from "./lib/security"; import { providerRegistry } from "./providers/registry"; +import canvasFetch from "./routes/canvas"; import cloudFetch from "./routes/cloud"; +import projectsFetch from "./routes/projects"; import userFetch from "./routes/user"; import conversationsFetch from "./routes/conversations"; import v1Fetch from "./routes/v1"; @@ -194,6 +196,8 @@ export function createApiApp() { app.use("/user/cloud/*", async (c) => await cloudFetch(c.req.raw)); app.use("/user/*", async (c) => await userFetch(c.req.raw)); app.use("/conversations/*", async (c) => await conversationsFetch(c.req.raw)); + app.use("/projects/*", async (c) => await projectsFetch(c.req.raw)); + app.use("/canvas/*", async (c) => await canvasFetch(c.req.raw)); app.use("/v1/*", async (c) => await v1Fetch(c.req.raw)); app.use("/:provider/*", async (c, next) => { diff --git a/server/lib/conversation-attachments.ts b/server/lib/conversation-attachments.ts index 2bfa2c4..38c7cb6 100644 --- a/server/lib/conversation-attachments.ts +++ b/server/lib/conversation-attachments.ts @@ -343,6 +343,15 @@ export function parseSingleMessagePart(rawPart: Record<string, unknown>): Conver return { modelLabel: rawPart.modelLabel, type: "meta" }; } + if ( + rawPart.type === "canvas" && + typeof rawPart.canvasId === "string" && + typeof rawPart.title === "string" && + typeof rawPart.kind === "string" + ) { + return { canvasId: rawPart.canvasId, kind: rawPart.kind, title: rawPart.title, type: "canvas" }; + } + return null; } @@ -389,6 +398,11 @@ export function hydrateMessageParts(input: { return result; } + if (part.type === "canvas") { + result.push(part); + return result; + } + const attachment = input.attachmentsById.get(part.attachmentId); if (!attachment) { return result; diff --git a/server/routes/canvas.ts b/server/routes/canvas.ts new file mode 100644 index 0000000..b359638 --- /dev/null +++ b/server/routes/canvas.ts @@ -0,0 +1,365 @@ +import type { Context } from "hono"; +import { Hono } from "hono"; +import { z } from "zod"; + +import { prisma } from "../lib/db"; +import { jsonErrorResponse } from "../lib/provider-core"; +import { authenticateAccess, protectedCors, securityHeaders } from "../lib/security"; +import { requireAuth } from "./route-helpers"; + +const app = new Hono().basePath("/canvas"); +app.use("*", securityHeaders); +app.use("*", protectedCors); +app.use("*", async (c, next) => { + const authError = await authenticateAccess(c); + if (authError) return authError; + return next(); +}); + +const canvasKindSchema = z.enum(["markdown", "code", "html", "react", "mermaid"]); + +const canvasUpdateSchema = z.object({ + content: z.string().max(2_000_000).optional(), + kind: canvasKindSchema.optional(), + language: z.string().max(64).nullable().optional(), + title: z.string().trim().min(1).max(200).optional(), +}); + +const canvasCreateInConversationSchema = z.object({ + content: z.string().min(1).max(2_000_000), + kind: canvasKindSchema, + language: z.string().max(64).nullable().optional(), + title: z.string().trim().min(1).max(200).optional(), +}); + +const pinSchema = z.object({ projectId: z.string().min(1) }); + +type CanvasRecord = { + activeVersion: number; + conversationId: string; + createdAt: Date; + id: string; + kind: string; + language: string | null; + shareToken: string | null; + title: string; + updatedAt: Date; +}; + +async function requireCanvas(c: Context, userId: string, canvasId: string) { + const canvas = await prisma.canvas.findFirst({ + where: { id: canvasId, conversation: { userId } }, + }); + return canvas ?? null; +} + +async function resolveCanvasDetail(canvasId: string) { + const canvas = await prisma.canvas.findUnique({ + where: { id: canvasId }, + include: { + versions: { orderBy: { version: "desc" }, select: { createdAt: true, kind: true, language: true, version: true } }, + }, + }); + if (!canvas) return null; + + const activeVersion = await prisma.canvasVersion.findUnique({ + where: { canvasId_version: { canvasId, version: canvas.activeVersion } }, + select: { content: true }, + }); + + return { + canvas: { + activeVersion: canvas.activeVersion, + content: activeVersion?.content ?? "", + conversationId: canvas.conversationId, + createdAt: canvas.createdAt, + id: canvas.id, + kind: canvas.kind, + language: canvas.language, + shareToken: canvas.shareToken, + title: canvas.title, + updatedAt: canvas.updatedAt, + versions: canvas.versions, + }, + }; +} + +type AuthorizedCanvas = { + canvas: NonNullable<Awaited<ReturnType<typeof requireCanvas>>>; + canvasId: string; + userId: string; +}; + +async function authorizeCanvas(c: Context): Promise<AuthorizedCanvas | Response> { + const userId = requireAuth(c); + if (typeof userId !== "string") return userId; + + const canvasId = c.req.param("id"); + if (!canvasId) return jsonErrorResponse(404, "Canvas not found"); + + const canvas = await requireCanvas(c, userId, canvasId); + if (!canvas) return jsonErrorResponse(404, "Canvas not found"); + + return { canvas, canvasId, userId }; +} + +// GET /canvas/:id — detalhe (content da versão ativa) +app.get("/:id", async (c) => { + const auth = await authorizeCanvas(c); + if (auth instanceof Response) return auth; + + const detail = await resolveCanvasDetail(auth.canvasId); + if (!detail) return jsonErrorResponse(404, "Canvas not found"); + + return c.json(detail); +}); + +// PATCH /canvas/:id — atualiza título/kind/language/content (content diferente ⇒ nova versão) +app.patch("/:id", async (c) => { + const auth = await authorizeCanvas(c); + if (auth instanceof Response) return auth; + const { canvas, canvasId } = auth; + + const parsed = canvasUpdateSchema.safeParse(await c.req.json().catch(() => ({}))); + if (!parsed.success) { + return jsonErrorResponse(400, "Invalid canvas payload"); + } + + const { content, kind, language, title } = parsed.data; + + if (content !== undefined && content !== canvas.content) { + const lastVersion = await prisma.canvasVersion.findFirst({ + where: { canvasId }, + orderBy: { version: "desc" }, + select: { version: true }, + }); + + await prisma.$transaction([ + prisma.canvasVersion.create({ + data: { + canvasId, + content, + kind: kind ?? canvas.kind, + language: language ?? canvas.language, + version: (lastVersion?.version ?? 0) + 1, + }, + }), + prisma.canvas.update({ + where: { id: canvasId }, + data: { + activeVersion: (lastVersion?.version ?? 0) + 1, + ...(content !== undefined ? { content } : {}), + ...(kind !== undefined ? { kind } : {}), + ...(language !== undefined ? { language } : {}), + ...(title !== undefined ? { title } : {}), + }, + }), + ]); + } else { + await prisma.canvas.update({ + where: { id: canvasId }, + data: { + ...(kind !== undefined ? { kind } : {}), + ...(language !== undefined ? { language } : {}), + ...(title !== undefined ? { title } : {}), + }, + }); + } + + const detail = await resolveCanvasDetail(canvasId); + if (!detail) return jsonErrorResponse(404, "Canvas not found"); + + return c.json(detail); +}); + +// DELETE /canvas/:id +app.delete("/:id", async (c) => { + const auth = await authorizeCanvas(c); + if (auth instanceof Response) return auth; + + await prisma.canvas.delete({ where: { id: auth.canvasId } }); + return c.json({ success: true }); +}); + +// GET /canvas/:id/versions — índice de versões (sem content) +app.get("/:id/versions", async (c) => { + const auth = await authorizeCanvas(c); + if (auth instanceof Response) return auth; + + const versions = await prisma.canvasVersion.findMany({ + where: { canvasId: auth.canvasId }, + select: { createdAt: true, kind: true, language: true, version: true }, + orderBy: { version: "desc" }, + }); + + return c.json({ versions }); +}); + +// GET /canvas/:id/versions/:version — versão com content +app.get("/:id/versions/:version", async (c) => { + const auth = await authorizeCanvas(c); + if (auth instanceof Response) return auth; + + const versionNumber = Number.parseInt(c.req.param("version") ?? "", 10); + if (!Number.isFinite(versionNumber)) { + return jsonErrorResponse(400, "Invalid version"); + } + + const version = await prisma.canvasVersion.findUnique({ + where: { canvasId_version: { canvasId: auth.canvasId, version: versionNumber } }, + select: { content: true, createdAt: true, kind: true, language: true, version: true }, + }); + + if (!version) return jsonErrorResponse(404, "Version not found"); + + return c.json({ version }); +}); + +// POST /canvas/:id/versions/:version/restore — restaura versão como nova versão ativa +app.post("/:id/versions/:version/restore", async (c) => { + const auth = await authorizeCanvas(c); + if (auth instanceof Response) return auth; + const { canvasId } = auth; + + const versionNumber = Number.parseInt(c.req.param("version") ?? "", 10); + if (!Number.isFinite(versionNumber)) { + return jsonErrorResponse(400, "Invalid version"); + } + + const source = await prisma.canvasVersion.findUnique({ + where: { canvasId_version: { canvasId, version: versionNumber } }, + select: { content: true, kind: true, language: true }, + }); + if (!source) return jsonErrorResponse(404, "Version not found"); + + const lastVersion = await prisma.canvasVersion.findFirst({ + where: { canvasId }, + orderBy: { version: "desc" }, + select: { version: true }, + }); + + await prisma.$transaction([ + prisma.canvasVersion.create({ + data: { + canvasId, + content: source.content, + kind: source.kind, + language: source.language, + version: (lastVersion?.version ?? 0) + 1, + }, + }), + prisma.canvas.update({ + where: { id: canvasId }, + data: { + activeVersion: (lastVersion?.version ?? 0) + 1, + content: source.content, + kind: source.kind, + language: source.language, + }, + }), + ]); + + const detail = await resolveCanvasDetail(canvasId); + if (!detail) return jsonErrorResponse(404, "Canvas not found"); + + return c.json(detail); +}); + +// POST /canvas/:id/share — gera (idempotente) token público +app.post("/:id/share", async (c) => { + const auth = await authorizeCanvas(c); + if (auth instanceof Response) return auth; + const { canvas, canvasId } = auth; + + if (canvas.shareToken) { + return c.json({ shareToken: canvas.shareToken }); + } + + const shareToken = crypto.randomUUID(); + await prisma.canvas.update({ where: { id: canvasId }, data: { shareToken } }); + return c.json({ shareToken }, 201); +}); + +// DELETE /canvas/:id/share — revoga token +app.delete("/:id/share", async (c) => { + const auth = await authorizeCanvas(c); + if (auth instanceof Response) return auth; + + await prisma.canvas.update({ + where: { id: auth.canvasId }, + data: { shareToken: null }, + }); + return c.json({ success: true }); +}); + +// POST /canvas/:id/pin — fixa snapshot do canvas em um projeto +app.post("/:id/pin", async (c) => { + const auth = await authorizeCanvas(c); + if (auth instanceof Response) return auth; + const { canvas, canvasId, userId } = auth; + + const parsed = pinSchema.safeParse(await c.req.json().catch(() => ({}))); + if (!parsed.success) { + return jsonErrorResponse(400, "projectId is required"); + } + + const project = await prisma.project.findFirst({ + where: { id: parsed.data.projectId, userId }, + select: { id: true }, + }); + if (!project) return jsonErrorResponse(404, "Project not found"); + + const existing = await prisma.projectArtifact.findFirst({ + where: { projectId: project.id, sourceCanvasId: canvasId }, + select: { id: true }, + }); + if (existing) { + return c.json({ artifactId: existing.id }, 409); + } + + const artifact = await prisma.projectArtifact.create({ + data: { + currentVersion: 1, + kind: canvas.kind, + language: canvas.language, + projectId: project.id, + sourceCanvasId: canvasId, + sourceConversationId: canvas.conversationId, + title: canvas.title, + versions: { + create: { content: canvas.content, version: 1 }, + }, + }, + include: { versions: { orderBy: { version: "desc" }, select: { createdAt: true, version: true } } }, + }); + + const activeContent = await prisma.artifactVersion.findUnique({ + where: { artifactId_version: { artifactId: artifact.id, version: 1 } }, + select: { content: true }, + }); + + return c.json( + { + artifact: { + content: activeContent?.content ?? canvas.content, + createdAt: artifact.createdAt, + currentVersion: artifact.currentVersion, + id: artifact.id, + kind: artifact.kind, + language: artifact.language, + projectId: artifact.projectId, + shareToken: artifact.shareToken, + sourceCanvasId: artifact.sourceCanvasId, + sourceConversationId: artifact.sourceConversationId, + title: artifact.title, + updatedAt: artifact.updatedAt, + versions: artifact.versions, + }, + }, + 201, + ); +}); + +export { canvasCreateInConversationSchema, canvasKindSchema }; +export type { CanvasRecord }; +export default app.fetch; diff --git a/server/routes/conversations.ts b/server/routes/conversations.ts index be36c68..fb6a75f 100644 --- a/server/routes/conversations.ts +++ b/server/routes/conversations.ts @@ -1,6 +1,7 @@ import type { Context } from "hono"; import { Hono } from "hono"; import type { Prisma } from "../../generated/prisma/client.ts"; +import { z } from "zod"; import { createMessageContentFallback, @@ -232,6 +233,7 @@ app.get("/", async (c) => { title: true, providerId: true, modelId: true, + projectId: true, archived: true, createdAt: true, updatedAt: true, @@ -250,23 +252,36 @@ app.post("/", async (c) => { const body = await c.req.json().catch(() => ({})) as { modelId?: string; + projectId?: string; providerId?: string; title?: string; }; + let projectId: string | null = null; + if (body.projectId) { + const project = await prisma.project.findFirst({ + where: { id: body.projectId, userId }, + select: { id: true }, + }); + if (!project) return jsonErrorResponse(404, "Project not found"); + projectId = project.id; + } + const conversation = await prisma.conversation.create({ data: { modelId: body.modelId ?? null, + projectId, providerId: body.providerId ?? null, title: body.title ?? "Nova conversa", userId, }, select: { + createdAt: true, id: true, - title: true, - providerId: true, modelId: true, - createdAt: true, + projectId: true, + providerId: true, + title: true, updatedAt: true, }, }); @@ -274,22 +289,34 @@ app.post("/", async (c) => { return c.json({ conversation }, 201); }); -// PATCH /conversations/:id — atualiza título +// PATCH /conversations/:id — atualiza título/projeto app.patch("/:id", async (c) => { const auth = await authorizeConversation(c); if (auth instanceof Response) return auth; - const { conversationId: id } = auth; + const { conversationId: id, userId } = auth; - const body = await c.req.json().catch(() => ({})) as { title?: string; archived?: boolean }; + const body = await c.req.json().catch(() => ({})) as { title?: string; archived?: boolean; projectId?: string | null }; const data: Record<string, unknown> = {}; if (body.title !== undefined) data.title = body.title; if (body.archived !== undefined) data.archived = body.archived; + if (body.projectId !== undefined) { + if (body.projectId === null) { + data.projectId = null; + } else { + const project = await prisma.project.findFirst({ + where: { id: body.projectId, userId }, + select: { id: true }, + }); + if (!project) return jsonErrorResponse(404, "Project not found"); + data.projectId = project.id; + } + } const conversation = await prisma.conversation.update({ where: { id }, data, - select: { id: true, title: true, archived: true, updatedAt: true }, + select: { archived: true, id: true, projectId: true, title: true, updatedAt: true }, }); return c.json({ conversation }); @@ -517,6 +544,91 @@ app.post("/:id/messages/:messageId/reaction", async (c) => { return c.json({ reaction }, 201); }); +// GET /conversations/:id/canvases — lista canvases da conversa +app.get("/:id/canvases", async (c) => { + const auth = await authorizeConversation(c); + if (auth instanceof Response) return auth; + const { conversationId } = auth; + + const canvases = await prisma.canvas.findMany({ + where: { conversationId }, + select: { + activeVersion: true, + conversationId: true, + createdAt: true, + id: true, + kind: true, + language: true, + shareToken: true, + title: true, + updatedAt: true, + }, + orderBy: { updatedAt: "desc" }, + }); + + return c.json({ canvases }); +}); + +// POST /conversations/:id/canvases — cria canvas na conversa (v1) +app.post("/:id/canvases", async (c) => { + const auth = await authorizeConversation(c); + if (auth instanceof Response) return auth; + const { conversationId } = auth; + + const parsed = z + .object({ + content: z.string().min(1).max(2_000_000), + kind: z.enum(["markdown", "code", "html", "react", "mermaid"]), + language: z.string().max(64).nullable().optional(), + title: z.string().trim().min(1).max(200).optional(), + }) + .safeParse(await c.req.json().catch(() => ({}))); + + if (!parsed.success) { + return jsonErrorResponse(400, "Invalid canvas payload"); + } + + const canvas = await prisma.canvas.create({ + data: { + content: parsed.data.content, + conversationId, + kind: parsed.data.kind, + language: parsed.data.language ?? null, + title: parsed.data.title ?? "Canvas", + versions: { + create: { + content: parsed.data.content, + kind: parsed.data.kind, + language: parsed.data.language ?? null, + version: 1, + }, + }, + }, + include: { + versions: { orderBy: { version: "desc" }, select: { createdAt: true, kind: true, language: true, version: true } }, + }, + }); + + return c.json( + { + canvas: { + activeVersion: canvas.activeVersion, + content: canvas.content, + conversationId: canvas.conversationId, + createdAt: canvas.createdAt, + id: canvas.id, + kind: canvas.kind, + language: canvas.language, + shareToken: canvas.shareToken, + title: canvas.title, + updatedAt: canvas.updatedAt, + versions: canvas.versions, + }, + }, + 201, + ); +}); + // POST /conversations/:id/share — generate share token app.post("/:id/share", async (c) => { const userId = requireAuth(c); diff --git a/server/routes/projects.ts b/server/routes/projects.ts new file mode 100644 index 0000000..9944b45 --- /dev/null +++ b/server/routes/projects.ts @@ -0,0 +1,489 @@ +import type { Context } from "hono"; +import { Hono } from "hono"; +import { z } from "zod"; + +import { + extractDocumentText, + getAttachmentValidationError, + readUploadedFile, + resolveAttachmentKind, +} from "../lib/conversation-attachments"; +import { prisma } from "../lib/db"; +import { jsonErrorResponse } from "../lib/provider-core"; +import { authenticateAccess, protectedCors, securityHeaders } from "../lib/security"; +import { requireAuth } from "./route-helpers"; + +const app = new Hono().basePath("/projects"); +app.use("*", securityHeaders); +app.use("*", protectedCors); +app.use("*", async (c, next) => { + const authError = await authenticateAccess(c); + if (authError) return authError; + return next(); +}); + +const CONTEXT_PER_FILE_CHAR_CAP = 20_000; +const CONTEXT_TOTAL_CHAR_CAP = 60_000; + +const projectCreateSchema = z.object({ + description: z.string().max(2000).optional(), + instructions: z.string().max(20_000).optional(), + name: z.string().trim().min(1).max(100), +}); + +const projectUpdateSchema = z.object({ + description: z.string().max(2000).nullable().optional(), + instructions: z.string().max(20_000).nullable().optional(), + name: z.string().trim().min(1).max(100).optional(), +}); + +async function requireProject(c: Context, userId: string, projectId: string) { + const project = await prisma.project.findFirst({ + where: { id: projectId, userId }, + }); + return project ?? null; +} + +type AuthorizedProject = { + project: NonNullable<Awaited<ReturnType<typeof requireProject>>>; + projectId: string; + userId: string; +}; + +async function authorizeProject(c: Context): Promise<AuthorizedProject | Response> { + const userId = requireAuth(c); + if (typeof userId !== "string") return userId; + + const projectId = c.req.param("id"); + if (!projectId) return jsonErrorResponse(404, "Project not found"); + + const project = await requireProject(c, userId, projectId); + if (!project) return jsonErrorResponse(404, "Project not found"); + + return { project, projectId, userId }; +} + +// GET /projects — lista projetos do usuário com contadores +app.get("/", async (c) => { + const userId = requireAuth(c); + if (typeof userId !== "string") return userId; + + const projects = await prisma.project.findMany({ + where: { userId }, + select: { + _count: { select: { artifacts: true, conversations: true, files: true } }, + createdAt: true, + description: true, + id: true, + name: true, + updatedAt: true, + }, + orderBy: { updatedAt: "desc" }, + take: 100, + }); + + return c.json({ + projects: projects.map((project) => ({ + counts: { + artifacts: project._count.artifacts, + conversations: project._count.conversations, + files: project._count.files, + }, + createdAt: project.createdAt, + description: project.description, + id: project.id, + name: project.name, + updatedAt: project.updatedAt, + })), + }); +}); + +// POST /projects — cria projeto +app.post("/", async (c) => { + const userId = requireAuth(c); + if (typeof userId !== "string") return userId; + + const parsed = projectCreateSchema.safeParse(await c.req.json().catch(() => ({}))); + if (!parsed.success) { + return jsonErrorResponse(400, "Invalid project payload"); + } + + const project = await prisma.project.create({ + data: { + description: parsed.data.description ?? null, + instructions: parsed.data.instructions ?? null, + name: parsed.data.name, + userId, + }, + select: { createdAt: true, description: true, id: true, instructions: true, name: true, updatedAt: true }, + }); + + return c.json({ project }, 201); +}); + +// GET /projects/:id — detalhe +app.get("/:id", async (c) => { + const auth = await authorizeProject(c); + if (auth instanceof Response) return auth; + + const counts = await prisma.project.findUnique({ + where: { id: auth.projectId }, + select: { _count: { select: { artifacts: true, conversations: true, files: true } } }, + }); + + return c.json({ + project: { + counts: { + artifacts: counts?._count.artifacts ?? 0, + conversations: counts?._count.conversations ?? 0, + files: counts?._count.files ?? 0, + }, + createdAt: auth.project.createdAt, + description: auth.project.description, + id: auth.project.id, + instructions: auth.project.instructions, + name: auth.project.name, + updatedAt: auth.project.updatedAt, + }, + }); +}); + +// PATCH /projects/:id — atualiza nome/descrição/instruções +app.patch("/:id", async (c) => { + const auth = await authorizeProject(c); + if (auth instanceof Response) return auth; + const { projectId } = auth; + + const parsed = projectUpdateSchema.safeParse(await c.req.json().catch(() => ({}))); + if (!parsed.success) { + return jsonErrorResponse(400, "Invalid project payload"); + } + + const project = await prisma.project.update({ + where: { id: projectId }, + data: parsed.data, + select: { createdAt: true, description: true, id: true, instructions: true, name: true, updatedAt: true }, + }); + + return c.json({ project }); +}); + +// DELETE /projects/:id — remove projeto (cascata: conversas, arquivos, artefatos) +app.delete("/:id", async (c) => { + const auth = await authorizeProject(c); + if (auth instanceof Response) return auth; + + await prisma.project.delete({ where: { id: auth.projectId } }); + return c.json({ success: true }); +}); + +// GET /projects/:id/conversations — conversas do projeto +app.get("/:id/conversations", async (c) => { + const auth = await authorizeProject(c); + if (auth instanceof Response) return auth; + const { projectId } = auth; + + const conversations = await prisma.conversation.findMany({ + where: { projectId, userId: auth.userId }, + select: { + archived: true, + createdAt: true, + id: true, + modelId: true, + providerId: true, + title: true, + updatedAt: true, + }, + orderBy: { updatedAt: "desc" }, + take: 100, + }); + + return c.json({ conversations }); +}); + +// POST /projects/:id/files — upload de arquivo de conhecimento +app.post("/:id/files", async (c) => { + const auth = await authorizeProject(c); + if (auth instanceof Response) return auth; + const { projectId } = auth; + + const formData = await c.req.raw.formData().catch(() => null); + if (!formData) { + return jsonErrorResponse(400, "Invalid multipart payload"); + } + + const fileValue = formData.get("file"); + if (!(fileValue instanceof File)) { + return jsonErrorResponse(400, "File is required"); + } + + const validationError = getAttachmentValidationError(fileValue); + if (validationError) { + return jsonErrorResponse(400, validationError); + } + + const kind = resolveAttachmentKind(fileValue.type); + if (!kind) { + return jsonErrorResponse(400, "Unsupported file type"); + } + + const buffer = await readUploadedFile(fileValue); + const extraction = + kind === "document" + ? await extractDocumentText({ buffer, mimeType: fileValue.type }) + : { extractedText: null, extractionStatus: "completed" as const }; + + const file = await prisma.projectFile.create({ + data: { + blob: buffer, + byteSize: fileValue.size, + extractedText: extraction.extractedText, + fileName: fileValue.name, + mimeType: fileValue.type, + projectId, + }, + select: { byteSize: true, createdAt: true, fileName: true, id: true, mimeType: true }, + }); + + return c.json({ file }, 201); +}); + +// GET /projects/:id/files — lista arquivos +app.get("/:id/files", async (c) => { + const auth = await authorizeProject(c); + if (auth instanceof Response) return auth; + const { projectId } = auth; + + const files = await prisma.projectFile.findMany({ + where: { projectId }, + select: { byteSize: true, createdAt: true, fileName: true, id: true, mimeType: true }, + orderBy: { createdAt: "desc" }, + }); + + return c.json({ files }); +}); + +// GET /projects/:id/files/:fileId/content — serve o binário autenticado +app.get("/:id/files/:fileId/content", async (c) => { + const auth = await authorizeProject(c); + if (auth instanceof Response) return auth; + const { projectId } = auth; + const fileId = c.req.param("fileId"); + + const file = await prisma.projectFile.findFirst({ + where: { id: fileId, projectId }, + select: { blob: true, fileName: true, mimeType: true }, + }); + + if (!file) { + return jsonErrorResponse(404, "File not found"); + } + + return new Response(new Uint8Array(file.blob), { + headers: { + "Cache-Control": "private, max-age=60", + "Content-Disposition": `inline; filename="${encodeURIComponent(file.fileName)}"`, + "Content-Type": file.mimeType, + }, + }); +}); + +// DELETE /projects/:id/files/:fileId +app.delete("/:id/files/:fileId", async (c) => { + const auth = await authorizeProject(c); + if (auth instanceof Response) return auth; + const { projectId } = auth; + const fileId = c.req.param("fileId"); + + const file = await prisma.projectFile.findFirst({ where: { id: fileId, projectId }, select: { id: true } }); + if (!file) return jsonErrorResponse(404, "File not found"); + + await prisma.projectFile.delete({ where: { id: file.id } }); + return c.json({ success: true }); +}); + +// GET /projects/:id/artifacts — artefatos do projeto +app.get("/:id/artifacts", async (c) => { + const auth = await authorizeProject(c); + if (auth instanceof Response) return auth; + const { projectId } = auth; + + const artifacts = await prisma.projectArtifact.findMany({ + where: { projectId }, + select: { + createdAt: true, + currentVersion: true, + id: true, + kind: true, + language: true, + projectId: true, + shareToken: true, + sourceCanvasId: true, + sourceConversationId: true, + title: true, + updatedAt: true, + }, + orderBy: { updatedAt: "desc" }, + }); + + return c.json({ artifacts }); +}); + +// GET /projects/:id/artifacts/:artifactId — detalhe (content da versão atual) +app.get("/:id/artifacts/:artifactId", async (c) => { + const auth = await authorizeProject(c); + if (auth instanceof Response) return auth; + const { projectId } = auth; + const artifactId = c.req.param("artifactId"); + + const artifact = await prisma.projectArtifact.findFirst({ + where: { id: artifactId, projectId }, + include: { + versions: { orderBy: { version: "desc" }, select: { createdAt: true, version: true } }, + }, + }); + + if (!artifact) return jsonErrorResponse(404, "Artifact not found"); + + const activeVersion = await prisma.artifactVersion.findUnique({ + where: { artifactId_version: { artifactId, version: artifact.currentVersion } }, + select: { content: true }, + }); + + return c.json({ + artifact: { + content: activeVersion?.content ?? "", + createdAt: artifact.createdAt, + currentVersion: artifact.currentVersion, + id: artifact.id, + kind: artifact.kind, + language: artifact.language, + projectId: artifact.projectId, + shareToken: artifact.shareToken, + sourceCanvasId: artifact.sourceCanvasId, + sourceConversationId: artifact.sourceConversationId, + title: artifact.title, + updatedAt: artifact.updatedAt, + versions: artifact.versions, + }, + }); +}); + +// DELETE /projects/:id/artifacts/:artifactId +app.delete("/:id/artifacts/:artifactId", async (c) => { + const auth = await authorizeProject(c); + if (auth instanceof Response) return auth; + const { projectId } = auth; + const artifactId = c.req.param("artifactId"); + + const artifact = await prisma.projectArtifact.findFirst({ + where: { id: artifactId, projectId }, + select: { id: true }, + }); + if (!artifact) return jsonErrorResponse(404, "Artifact not found"); + + await prisma.projectArtifact.delete({ where: { id: artifact.id } }); + return c.json({ success: true }); +}); + +// POST /projects/:id/artifacts/:artifactId/refresh — nova versão snapshot do canvas de origem +app.post("/:id/artifacts/:artifactId/refresh", async (c) => { + const auth = await authorizeProject(c); + if (auth instanceof Response) return auth; + const { projectId, userId } = auth; + const artifactId = c.req.param("artifactId"); + + const body = await c.req.json().catch(() => ({})) as { canvasId?: string }; + const canvasId = body.canvasId; + if (!canvasId) return jsonErrorResponse(400, "canvasId is required"); + + const artifact = await prisma.projectArtifact.findFirst({ + where: { id: artifactId, projectId }, + }); + if (!artifact) return jsonErrorResponse(404, "Artifact not found"); + if (artifact.sourceCanvasId !== canvasId) { + return jsonErrorResponse(409, "Artifact is not linked to this canvas"); + } + + const canvas = await prisma.canvas.findFirst({ + where: { id: canvasId, conversation: { userId } }, + }); + if (!canvas) return jsonErrorResponse(404, "Canvas not found"); + + const lastVersion = await prisma.artifactVersion.findFirst({ + where: { artifactId }, + orderBy: { version: "desc" }, + select: { version: true }, + }); + + const nextVersion = (lastVersion?.version ?? 0) + 1; + await prisma.$transaction([ + prisma.artifactVersion.create({ + data: { artifactId, content: canvas.content, version: nextVersion }, + }), + prisma.projectArtifact.update({ + where: { id: artifactId }, + data: { + currentVersion: nextVersion, + kind: canvas.kind, + language: canvas.language, + title: canvas.title, + }, + }), + ]); + + const refreshed = await prisma.projectArtifact.findFirst({ + where: { id: artifactId, projectId }, + include: { versions: { orderBy: { version: "desc" }, select: { createdAt: true, version: true } } }, + }); + + const activeContent = await prisma.artifactVersion.findUnique({ + where: { artifactId_version: { artifactId, version: nextVersion } }, + select: { content: true }, + }); + + return c.json({ + artifact: { + content: activeContent?.content ?? "", + createdAt: refreshed?.createdAt, + currentVersion: nextVersion, + id: artifactId, + kind: refreshed?.kind, + language: refreshed?.language, + projectId, + shareToken: refreshed?.shareToken, + sourceCanvasId: refreshed?.sourceCanvasId, + sourceConversationId: refreshed?.sourceConversationId, + title: refreshed?.title, + updatedAt: refreshed?.updatedAt, + versions: refreshed?.versions ?? [], + }, + }); +}); + +// GET /projects/:id/context — instruções + knowledge para injeção no system prompt +app.get("/:id/context", async (c) => { + const auth = await authorizeProject(c); + if (auth instanceof Response) return auth; + const { project, projectId } = auth; + + const files = await prisma.projectFile.findMany({ + where: { projectId, extractedText: { not: null } }, + select: { extractedText: true, fileName: true }, + orderBy: { createdAt: "asc" }, + }); + + let remaining = CONTEXT_TOTAL_CHAR_CAP; + const knowledge: Array<{ fileName: string; text: string }> = []; + for (const file of files) { + if (remaining <= 0) break; + const text = (file.extractedText ?? "").slice(0, Math.min(CONTEXT_PER_FILE_CHAR_CAP, remaining)); + if (!text) continue; + knowledge.push({ fileName: file.fileName, text }); + remaining -= text.length; + } + + return c.json({ instructions: project.instructions, knowledge }); +}); + +export default app.fetch; diff --git a/server/tests/canvas-routes.test.ts b/server/tests/canvas-routes.test.ts new file mode 100644 index 0000000..b216cb3 --- /dev/null +++ b/server/tests/canvas-routes.test.ts @@ -0,0 +1,501 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const getSession = vi.fn(); + +type ConversationRecord = { + createdAt: Date; + id: string; + title: string; + updatedAt: Date; + userId: string; +}; + +type CanvasRecord = { + activeVersion: number; + content: string; + conversationId: string; + createdAt: Date; + id: string; + kind: string; + language: string | null; + shareToken: string | null; + title: string; + updatedAt: Date; +}; + +type CanvasVersionRecord = { + canvasId: string; + content: string; + createdAt: Date; + id: string; + kind: string; + language: string | null; + version: number; +}; + +type ProjectRecord = { + createdAt: Date; + description: string | null; + id: string; + instructions: string | null; + name: string; + updatedAt: Date; + userId: string; +}; + +type ArtifactRecord = { + createdAt: Date; + currentVersion: number; + id: string; + kind: string; + language: string | null; + projectId: string; + shareToken: string | null; + sourceCanvasId: string | null; + sourceConversationId: string | null; + title: string; + updatedAt: Date; +}; + +type ArtifactVersionRecord = { + artifactId: string; + content: string; + createdAt: Date; + id: string; + version: number; +}; + +let canvasCounter = 1; +let canvasVersionCounter = 1; +let artifactCounter = 1; + +const state: { + artifacts: ArtifactRecord[]; + artifactVersions: ArtifactVersionRecord[]; + canvases: CanvasRecord[]; + canvasVersions: CanvasVersionRecord[]; + conversations: ConversationRecord[]; + projects: ProjectRecord[]; +} = { + artifacts: [], + artifactVersions: [], + canvases: [], + canvasVersions: [], + conversations: [], + projects: [], +}; + +function now() { + return new Date("2026-03-28T12:00:00.000Z"); +} + +function resetState() { + canvasCounter = 1; + canvasVersionCounter = 1; + artifactCounter = 1; + state.artifacts = []; + state.artifactVersions = []; + state.canvases = []; + state.canvasVersions = []; + state.conversations = [{ + createdAt: now(), + id: "conv-1", + title: "Nova conversa", + updatedAt: now(), + userId: "user-1", + }]; + state.projects = [{ + createdAt: now(), + description: null, + id: "proj-1", + instructions: "Sempre responda em pt-BR", + name: "Projeto Alpha", + updatedAt: now(), + userId: "user-1", + }]; +} + +const mockPrisma = { + $transaction: vi.fn(async (operations: unknown) => + Array.isArray(operations) ? Promise.all(operations) : operations), + apiKey: { + findFirst: vi.fn().mockResolvedValue(null), + update: vi.fn().mockResolvedValue(null), + }, + providerCredential: { findMany: vi.fn().mockResolvedValue([]) }, + usageLog: { create: vi.fn().mockReturnValue({ catch: vi.fn() }) }, + user: { upsert: vi.fn().mockResolvedValue(null) }, + artifactVersion: { + create: vi.fn(async ({ data }: { data: Omit<ArtifactVersionRecord, "createdAt" | "id"> }) => { + const version: ArtifactVersionRecord = { + ...data, + createdAt: now(), + id: `artifact-version-${canvasVersionCounter += 1}`, + }; + state.artifactVersions.push(version); + return version; + }), + findUnique: vi.fn(async ({ where }: { where: { artifactId_version: { artifactId: string; version: number } } }) => + state.artifactVersions.find( + (version) => + version.artifactId === where.artifactId_version.artifactId && + version.version === where.artifactId_version.version, + ) ?? null), + }, + canvas: { + create: vi.fn(async ({ data, include }: { + data: Partial<CanvasRecord> & { + versions?: { create: Omit<CanvasVersionRecord, "createdAt" | "id"> }; + }; + include?: { versions?: unknown }; + }) => { + const canvas: CanvasRecord = { + activeVersion: data.activeVersion ?? 1, + content: data.content ?? "", + conversationId: data.conversationId ?? "", + createdAt: now(), + id: `canvas-${canvasCounter += 1}`, + kind: data.kind ?? "markdown", + language: data.language ?? null, + shareToken: null, + title: data.title ?? "Canvas", + updatedAt: now(), + }; + state.canvases.push(canvas); + + if (data.versions?.create) { + const nested = data.versions.create; + state.canvasVersions.push({ + ...nested, + canvasId: canvas.id, + createdAt: now(), + id: `cv-${canvasVersionCounter += 1}`, + }); + } + + if (include?.versions) { + return { + ...canvas, + versions: state.canvasVersions + .filter((version) => version.canvasId === canvas.id) + .sort((left, right) => right.version - left.version) + .map(({ createdAt, kind, language, version }) => ({ createdAt, kind, language, version })), + }; + } + + return canvas; + }), + delete: vi.fn(async ({ where }: { where: { id: string } }) => { + state.canvases = state.canvases.filter((canvas) => canvas.id !== where.id); + state.canvasVersions = state.canvasVersions.filter((version) => version.canvasId !== where.id); + return { id: where.id }; + }), + findFirst: vi.fn(async ({ where }: { + where: { conversation?: { userId?: string }; id?: string }; + }) => + state.canvases.find((canvas) => { + if (where.id && canvas.id !== where.id) return false; + if (where.conversation?.userId) { + const conversation = state.conversations.find((entry) => entry.id === canvas.conversationId); + if (conversation?.userId !== where.conversation.userId) return false; + } + return true; + }) ?? null), + findUnique: vi.fn(async ({ where, include }: { + include?: { versions?: unknown }; + where: { id?: string } | { canvasId_version?: { canvasId: string; version: number } }; + }) => { + if ("canvasId_version" in where) { + const canvasId = where.canvasId_version?.canvasId; + const versionNumber = where.canvasId_version?.version; + return state.canvasVersions.find( + (version) => version.canvasId === canvasId && version.version === versionNumber, + ) ?? null; + } + + const canvas = state.canvases.find((entry) => entry.id === (where as { id?: string }).id) ?? null; + if (!canvas) return null; + if (include?.versions) { + return { + ...canvas, + versions: state.canvasVersions + .filter((version) => version.canvasId === canvas.id) + .sort((left, right) => right.version - left.version) + .map(({ createdAt, kind, language, version }) => ({ createdAt, kind, language, version })), + }; + } + return canvas; + }), + update: vi.fn(async ({ where, data }: { data: Partial<CanvasRecord>; where: { id: string } }) => { + const canvas = state.canvases.find((entry) => entry.id === where.id); + if (!canvas) throw new Error(`Canvas ${where.id} not found`); + Object.assign(canvas, { ...data, updatedAt: now() }); + return canvas; + }), + }, + canvasVersion: { + create: vi.fn(async ({ data }: { data: Omit<CanvasVersionRecord, "createdAt" | "id"> }) => { + const version: CanvasVersionRecord = { + ...data, + createdAt: now(), + id: `cv-${canvasVersionCounter += 1}`, + }; + state.canvasVersions.push(version); + return version; + }), + findFirst: vi.fn(async ({ where, orderBy }: { + orderBy?: { version?: string }; + where: { canvasId: string }; + }) => { + const versions = state.canvasVersions + .filter((version) => version.canvasId === where.canvasId) + .sort((left, right) => + orderBy?.version === "desc" ? right.version - left.version : left.version - right.version); + return versions[0] ?? null; + }), + findMany: vi.fn(async ({ where }: { where: { canvasId: string } }) => + state.canvasVersions + .filter((version) => version.canvasId === where.canvasId) + .sort((left, right) => right.version - left.version)), + findUnique: vi.fn(async ({ where }: { where: { canvasId_version: { canvasId: string; version: number } } }) => + state.canvasVersions.find( + (version) => + version.canvasId === where.canvasId_version.canvasId && + version.version === where.canvasId_version.version, + ) ?? null), + }, + conversation: { + findFirst: vi.fn(async ({ where }: { where: { id?: string; userId?: string } }) => + state.conversations.find((conversation) => + (!where.id || conversation.id === where.id) && + (!where.userId || conversation.userId === where.userId), + ) ?? null), + }, + project: { + findFirst: vi.fn(async ({ where }: { where: { id?: string; userId?: string } }) => + state.projects.find((project) => + (!where.id || project.id === where.id) && + (!where.userId || project.userId === where.userId), + ) ?? null), + }, + projectArtifact: { + create: vi.fn(async ({ data, include }: { + data: Partial<ArtifactRecord> & { versions?: { create: Omit<ArtifactVersionRecord, "createdAt" | "id"> } }; + include?: { versions?: unknown }; + }) => { + const artifact: ArtifactRecord = { + createdAt: now(), + currentVersion: data.currentVersion ?? 1, + id: `artifact-${artifactCounter += 1}`, + kind: data.kind ?? "markdown", + language: data.language ?? null, + projectId: data.projectId ?? "", + shareToken: null, + sourceCanvasId: data.sourceCanvasId ?? null, + sourceConversationId: data.sourceConversationId ?? null, + title: data.title ?? "Artefato", + updatedAt: now(), + }; + state.artifacts.push(artifact); + + if (data.versions?.create) { + state.artifactVersions.push({ + ...data.versions.create, + artifactId: artifact.id, + createdAt: now(), + id: `artifact-version-${canvasVersionCounter += 1}`, + }); + } + + if (include?.versions) { + return { + ...artifact, + versions: state.artifactVersions + .filter((version) => version.artifactId === artifact.id) + .sort((left, right) => right.version - left.version) + .map(({ createdAt, version }) => ({ createdAt, version })), + }; + } + + return artifact; + }), + findFirst: vi.fn(async ({ where }: { + where: { id?: string; projectId?: string; sourceCanvasId?: string }; + }) => + state.artifacts.find((artifact) => + (!where.id || artifact.id === where.id) && + (!where.projectId || artifact.projectId === where.projectId) && + (!where.sourceCanvasId || artifact.sourceCanvasId === where.sourceCanvasId), + ) ?? null), + update: vi.fn(async ({ where, data }: { data: Partial<ArtifactRecord>; where: { id: string } }) => { + const artifact = state.artifacts.find((entry) => entry.id === where.id); + if (!artifact) throw new Error(`Artifact ${where.id} not found`); + Object.assign(artifact, { ...data, updatedAt: now() }); + return artifact; + }), + }, +}; + +vi.mock("../lib/db", () => ({ prisma: mockPrisma })); +vi.mock("../env", () => ({})); +vi.mock("@/lib/auth/server", () => ({ auth: { getSession } })); + +const canvasFetch = (await import("../routes/canvas")).default; + +function seedCanvas(overrides: Partial<CanvasRecord> = {}): CanvasRecord { + const canvas: CanvasRecord = { + activeVersion: 1, + content: "# Documento\n\nConteúdo inicial.", + conversationId: "conv-1", + createdAt: now(), + id: `canvas-${canvasCounter += 1}`, + kind: "markdown", + language: null, + shareToken: null, + title: "Documento", + updatedAt: now(), + ...overrides, + }; + state.canvases.push(canvas); + state.canvasVersions.push({ + canvasId: canvas.id, + content: canvas.content, + createdAt: now(), + id: `cv-${canvasVersionCounter += 1}`, + kind: canvas.kind, + language: canvas.language, + version: 1, + }); + return canvas; +} + +describe("canvas routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + resetState(); + getSession.mockResolvedValue({ + data: { + session: { id: "session-1" }, + user: { email: "user@example.com", id: "user-1", name: "User" }, + }, + }); + }); + + afterEach(() => { + getSession.mockReset(); + }); + + it("retorna 404 para canvas de outro usuário", async () => { + state.conversations.push({ createdAt: now(), id: "conv-other", title: "Outro", updatedAt: now(), userId: "user-2" }); + const canvas = seedCanvas({ conversationId: "conv-other" }); + + const response = await canvasFetch(new Request(`http://localhost/canvas/${canvas.id}`)); + expect(response.status).toBe(404); + }); + + it("PATCH cria nova versão quando o conteúdo difere e não cria quando é idêntico", async () => { + const canvas = seedCanvas(); + + const sameContent = await canvasFetch(new Request(`http://localhost/canvas/${canvas.id}`, { + body: JSON.stringify({ content: canvas.content }), + headers: { "content-type": "application/json" }, + method: "PATCH", + })); + expect(sameContent.status).toBe(200); + expect(state.canvasVersions.filter((version) => version.canvasId === canvas.id)).toHaveLength(1); + + const changed = await canvasFetch(new Request(`http://localhost/canvas/${canvas.id}`, { + body: JSON.stringify({ content: "# Documento\n\nConteúdo revisado." }), + headers: { "content-type": "application/json" }, + method: "PATCH", + })); + expect(changed.status).toBe(200); + const payload = await changed.json() as { canvas: { activeVersion: number; content: string } }; + expect(payload.canvas.activeVersion).toBe(2); + expect(payload.canvas.content).toContain("revisado"); + expect(state.canvasVersions.filter((version) => version.canvasId === canvas.id)).toHaveLength(2); + }); + + it("restaura uma versão anterior como nova versão ativa", async () => { + const canvas = seedCanvas(); + + await canvasFetch(new Request(`http://localhost/canvas/${canvas.id}`, { + body: JSON.stringify({ content: "Versão 2" }), + headers: { "content-type": "application/json" }, + method: "PATCH", + })); + + const restore = await canvasFetch(new Request(`http://localhost/canvas/${canvas.id}/versions/1/restore`, { + method: "POST", + })); + expect(restore.status).toBe(200); + const payload = await restore.json() as { canvas: { activeVersion: number; content: string } }; + expect(payload.canvas.activeVersion).toBe(3); + expect(payload.canvas.content).toBe(canvas.content); + expect(state.canvasVersions.filter((version) => version.canvasId === canvas.id)).toHaveLength(3); + }); + + it("compartilhamento é idempotente e revogável", async () => { + const canvas = seedCanvas(); + + const first = await canvasFetch(new Request(`http://localhost/canvas/${canvas.id}/share`, { method: "POST" })); + expect(first.status).toBe(201); + const firstPayload = await first.json() as { shareToken: string }; + expect(firstPayload.shareToken).toBeTruthy(); + + const second = await canvasFetch(new Request(`http://localhost/canvas/${canvas.id}/share`, { method: "POST" })); + expect(second.status).toBe(200); + const secondPayload = await second.json() as { shareToken: string }; + expect(secondPayload.shareToken).toBe(firstPayload.shareToken); + + const revoke = await canvasFetch(new Request(`http://localhost/canvas/${canvas.id}/share`, { method: "DELETE" })); + expect(revoke.status).toBe(200); + expect(state.canvases.find((entry) => entry.id === canvas.id)?.shareToken).toBeNull(); + }); + + it("fixa snapshot do canvas em projeto e devolve 409 em duplicata", async () => { + const canvas = seedCanvas({ content: "graph TD\nA-->B" , kind: "mermaid", title: "Diagrama" }); + + const pin = await canvasFetch(new Request(`http://localhost/canvas/${canvas.id}/pin`, { + body: JSON.stringify({ projectId: "proj-1" }), + headers: { "content-type": "application/json" }, + method: "POST", + })); + expect(pin.status).toBe(201); + const pinPayload = await pin.json() as { artifact: { content: string; currentVersion: number; id?: string; kind: string; sourceCanvasId: string } }; + expect(pinPayload.artifact.currentVersion).toBe(1); + expect(pinPayload.artifact.kind).toBe("mermaid"); + expect(pinPayload.artifact.sourceCanvasId).toBe(canvas.id); + expect(pinPayload.artifact.content).toContain("graph TD"); + expect(state.artifactVersions).toHaveLength(1); + + const duplicate = await canvasFetch(new Request(`http://localhost/canvas/${canvas.id}/pin`, { + body: JSON.stringify({ projectId: "proj-1" }), + headers: { "content-type": "application/json" }, + method: "POST", + })); + expect(duplicate.status).toBe(409); + const duplicatePayload = await duplicate.json() as { artifactId: string }; + expect(duplicatePayload.artifactId).toBe(pinPayload.artifact.id ?? state.artifacts[0]?.id); + }); + + it("pin em projeto de outro usuário retorna 404", async () => { + state.projects.push({ + createdAt: now(), + description: null, + id: "proj-other", + instructions: null, + name: "Outro usuário", + updatedAt: now(), + userId: "user-2", + }); + const canvas = seedCanvas(); + + const response = await canvasFetch(new Request(`http://localhost/canvas/${canvas.id}/pin`, { + body: JSON.stringify({ projectId: "proj-other" }), + headers: { "content-type": "application/json" }, + method: "POST", + })); + expect(response.status).toBe(404); + }); +}); diff --git a/server/tests/projects-routes.test.ts b/server/tests/projects-routes.test.ts new file mode 100644 index 0000000..3361c2f --- /dev/null +++ b/server/tests/projects-routes.test.ts @@ -0,0 +1,465 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const getSession = vi.fn(); + +type ProjectRecord = { + createdAt: Date; + description: string | null; + id: string; + instructions: string | null; + name: string; + updatedAt: Date; + userId: string; +}; + +type ProjectFileRecord = { + byteSize: number; + createdAt: Date; + extractedText: string | null; + fileName: string; + id: string; + mimeType: string; + projectId: string; +}; + +type ArtifactRecord = { + createdAt: Date; + currentVersion: number; + id: string; + kind: string; + language: string | null; + projectId: string; + shareToken: string | null; + sourceCanvasId: string | null; + sourceConversationId: string | null; + title: string; + updatedAt: Date; +}; + +type ArtifactVersionRecord = { + artifactId: string; + content: string; + createdAt: Date; + id: string; + version: number; +}; + +type CanvasRecord = { + activeVersion: number; + content: string; + conversationId: string; + id: string; + kind: string; + language: string | null; + title: string; + userId: string; +}; + +type ConversationRecord = { + archived: boolean; + createdAt: Date; + id: string; + projectId: string | null; + title: string; + updatedAt: Date; + userId: string; +}; + +let projectCounter = 1; +let fileCounter = 1; +let artifactCounter = 1; +let artifactVersionCounter = 1; + +const state: { + artifactVersions: ArtifactVersionRecord[]; + artifacts: ArtifactRecord[]; + canvases: CanvasRecord[]; + conversations: ConversationRecord[]; + files: ProjectFileRecord[]; + projects: ProjectRecord[]; +} = { + artifactVersions: [], + artifacts: [], + canvases: [], + conversations: [], + files: [], + projects: [], +}; + +function now() { + return new Date("2026-03-28T12:00:00.000Z"); +} + +function resetState() { + projectCounter = 1; + fileCounter = 1; + artifactCounter = 1; + artifactVersionCounter = 1; + state.artifactVersions = []; + state.artifacts = []; + state.canvases = []; + state.conversations = []; + state.files = []; + state.projects = [{ + createdAt: now(), + description: "Projeto de testes", + id: "proj-1", + instructions: "Sempre responda em pt-BR", + name: "Projeto Alpha", + updatedAt: now(), + userId: "user-1", + }]; +} + +function projectCounts(projectId: string) { + return { + artifacts: state.artifacts.filter((artifact) => artifact.projectId === projectId).length, + conversations: state.conversations.filter((conversation) => conversation.projectId === projectId).length, + files: state.files.filter((file) => file.projectId === projectId).length, + }; +} + +const mockPrisma = { + $transaction: vi.fn(async (operations: unknown) => + Array.isArray(operations) ? Promise.all(operations) : operations), + apiKey: { + findFirst: vi.fn().mockResolvedValue(null), + update: vi.fn().mockResolvedValue(null), + }, + providerCredential: { findMany: vi.fn().mockResolvedValue([]) }, + usageLog: { create: vi.fn().mockReturnValue({ catch: vi.fn() }) }, + user: { upsert: vi.fn().mockResolvedValue(null) }, + artifactVersion: { + create: vi.fn(async ({ data }: { data: Omit<ArtifactVersionRecord, "createdAt" | "id"> }) => { + const version: ArtifactVersionRecord = { + ...data, + createdAt: now(), + id: `artifact-version-${artifactVersionCounter += 1}`, + }; + state.artifactVersions.push(version); + return version; + }), + findUnique: vi.fn(async ({ where }: { where: { artifactId_version: { artifactId: string; version: number } } }) => + state.artifactVersions.find( + (version) => + version.artifactId === where.artifactId_version.artifactId && + version.version === where.artifactId_version.version, + ) ?? null), + findFirst: vi.fn(async ({ where }: { where: { artifactId: string } }) => { + const versions = state.artifactVersions + .filter((version) => version.artifactId === where.artifactId) + .sort((left, right) => right.version - left.version); + return versions[0] ?? null; + }), + }, + canvas: { + findFirst: vi.fn(async ({ where }: { where: { conversation?: { userId?: string }; id?: string } }) => + state.canvases.find((canvas) => { + if (where.id && canvas.id !== where.id) return false; + if (where.conversation?.userId && canvas.userId !== where.conversation.userId) return false; + return true; + }) ?? null), + }, + conversation: { + findMany: vi.fn(async ({ where }: { where: { projectId?: string; userId?: string } }) => + state.conversations.filter((conversation) => + (!where.projectId || conversation.projectId === where.projectId) && + (!where.userId || conversation.userId === where.userId), + )), + }, + project: { + create: vi.fn(async ({ data }: { data: Partial<ProjectRecord> }) => { + const project: ProjectRecord = { + createdAt: now(), + description: data.description ?? null, + id: `proj-${projectCounter += 1}`, + instructions: data.instructions ?? null, + name: data.name ?? "Projeto", + updatedAt: now(), + userId: data.userId ?? "user-1", + }; + state.projects.push(project); + return project; + }), + delete: vi.fn(async ({ where }: { where: { id: string } }) => { + state.projects = state.projects.filter((project) => project.id !== where.id); + state.conversations = state.conversations.filter((conversation) => conversation.projectId !== where.id); + state.files = state.files.filter((file) => file.projectId !== where.id); + state.artifacts = state.artifacts.filter((artifact) => artifact.projectId !== where.id); + return { id: where.id }; + }), + findFirst: vi.fn(async ({ where }: { where: { id?: string; userId?: string } }) => + state.projects.find((project) => + (!where.id || project.id === where.id) && + (!where.userId || project.userId === where.userId), + ) ?? null), + findMany: vi.fn(async ({ where }: { where: { userId: string } }) => + state.projects + .filter((project) => project.userId === where.userId) + .map((project) => ({ ...project, _count: projectCounts(project.id) }))), + findUnique: vi.fn(async ({ where }: { where: { id: string } }) => + state.projects.find((project) => project.id === where.id) ?? null), + update: vi.fn(async ({ where, data }: { data: Partial<ProjectRecord>; where: { id: string } }) => { + const project = state.projects.find((entry) => entry.id === where.id); + if (!project) throw new Error(`Project ${where.id} not found`); + Object.assign(project, { ...data, updatedAt: now() }); + return project; + }), + }, + projectArtifact: { + create: vi.fn(async ({ data }: { data: Partial<ArtifactRecord> }) => { + const artifact: ArtifactRecord = { + createdAt: now(), + currentVersion: data.currentVersion ?? 1, + id: `artifact-${artifactCounter += 1}`, + kind: data.kind ?? "markdown", + language: data.language ?? null, + projectId: data.projectId ?? "", + shareToken: null, + sourceCanvasId: data.sourceCanvasId ?? null, + sourceConversationId: data.sourceConversationId ?? null, + title: data.title ?? "Artefato", + updatedAt: now(), + }; + state.artifacts.push(artifact); + return artifact; + }), + delete: vi.fn(async ({ where }: { where: { id: string } }) => { + state.artifacts = state.artifacts.filter((artifact) => artifact.id !== where.id); + state.artifactVersions = state.artifactVersions.filter((version) => version.artifactId !== where.id); + return { id: where.id }; + }), + findFirst: vi.fn(async ({ where, include }: { + include?: { versions?: unknown }; + where: { id?: string; projectId?: string }; + }) => { + const artifact = state.artifacts.find((entry) => + (!where.id || entry.id === where.id) && + (!where.projectId || entry.projectId === where.projectId), + ) ?? null; + if (!artifact) return null; + if (include?.versions) { + return { + ...artifact, + versions: state.artifactVersions + .filter((version) => version.artifactId === artifact.id) + .sort((left, right) => right.version - left.version) + .map(({ createdAt, version }) => ({ createdAt, version })), + }; + } + return artifact; + }), + findMany: vi.fn(async ({ where }: { where: { projectId: string } }) => + state.artifacts.filter((artifact) => artifact.projectId === where.projectId)), + update: vi.fn(async ({ where, data }: { data: Partial<ArtifactRecord>; where: { id: string } }) => { + const artifact = state.artifacts.find((entry) => entry.id === where.id); + if (!artifact) throw new Error(`Artifact ${where.id} not found`); + Object.assign(artifact, { ...data, updatedAt: now() }); + return artifact; + }), + }, + projectFile: { + create: vi.fn(async ({ data }: { data: Omit<ProjectFileRecord, "createdAt" | "id"> }) => { + const file: ProjectFileRecord = { + ...data, + createdAt: now(), + id: `pfile-${fileCounter += 1}`, + }; + state.files.push(file); + return file; + }), + delete: vi.fn(async ({ where }: { where: { id: string } }) => { + state.files = state.files.filter((file) => file.id !== where.id); + return { id: where.id }; + }), + findFirst: vi.fn(async ({ where }: { where: { id?: string; projectId?: string } }) => + state.files.find((file) => + (!where.id || file.id === where.id) && + (!where.projectId || file.projectId === where.projectId), + ) ?? null), + findMany: vi.fn(async ({ where }: { where: { extractedText?: { not: null }; projectId?: string } }) => + state.files.filter((file) => { + if (where.projectId && file.projectId !== where.projectId) return false; + if (where.extractedText && file.extractedText === null) return false; + return true; + })), + }, +}; + +vi.mock("../lib/db", () => ({ prisma: mockPrisma })); +vi.mock("../env", () => ({})); +vi.mock("@/lib/auth/server", () => ({ auth: { getSession } })); + +const projectsFetch = (await import("../routes/projects")).default; + +function seedFile(projectId: string, fileName: string, extractedText: string | null): ProjectFileRecord { + const file: ProjectFileRecord = { + byteSize: 100, + createdAt: now(), + extractedText, + fileName, + id: `pfile-${fileCounter += 1}`, + mimeType: "application/pdf", + projectId, + }; + state.files.push(file); + return file; +} + +function seedArtifact(projectId: string, sourceCanvasId: string, content: string): ArtifactRecord { + const artifact: ArtifactRecord = { + createdAt: now(), + currentVersion: 1, + id: `artifact-${artifactCounter += 1}`, + kind: "markdown", + language: null, + projectId, + shareToken: null, + sourceCanvasId, + sourceConversationId: null, + title: "Artefato", + updatedAt: now(), + }; + state.artifacts.push(artifact); + state.artifactVersions.push({ + artifactId: artifact.id, + content, + createdAt: now(), + id: `artifact-version-${artifactVersionCounter += 1}`, + version: 1, + }); + return artifact; +} + +describe("projects routes", () => { + beforeEach(() => { + vi.clearAllMocks(); + resetState(); + getSession.mockResolvedValue({ + data: { + session: { id: "session-1" }, + user: { email: "user@example.com", id: "user-1", name: "User" }, + }, + }); + }); + + afterEach(() => { + getSession.mockReset(); + }); + + it("lista projetos com contadores", async () => { + state.conversations.push({ archived: false, createdAt: now(), id: "conv-1", projectId: "proj-1", title: "Chat", updatedAt: now(), userId: "user-1" }); + seedFile("proj-1", "doc.pdf", "texto extraído"); + seedArtifact("proj-1", "canvas-1", "conteúdo"); + + const response = await projectsFetch(new Request("http://localhost/projects")); + expect(response.status).toBe(200); + const payload = await response.json() as { + projects: Array<{ counts: { artifacts: number; conversations: number; files: number }; id: string }>; + }; + + expect(payload.projects).toHaveLength(1); + expect(payload.projects[0]?.counts).toEqual({ artifacts: 1, conversations: 1, files: 1 }); + }); + + it("cria e atualiza projeto", async () => { + const created = await projectsFetch(new Request("http://localhost/projects", { + body: JSON.stringify({ description: "d", instructions: "i", name: "Novo" }), + headers: { "content-type": "application/json" }, + method: "POST", + })); + expect(created.status).toBe(201); + const createdPayload = await created.json() as { project: { id: string; instructions: string | null; name: string } }; + expect(createdPayload.project.name).toBe("Novo"); + expect(createdPayload.project.instructions).toBe("i"); + + const patched = await projectsFetch(new Request(`http://localhost/projects/${createdPayload.project.id}`, { + body: JSON.stringify({ name: "Renomeado" }), + headers: { "content-type": "application/json" }, + method: "PATCH", + })); + expect(patched.status).toBe(200); + const patchedPayload = await patched.json() as { project: { name: string } }; + expect(patchedPayload.project.name).toBe("Renomeado"); + }); + + it("rejeita payload inválido e projeto de outro usuário", async () => { + const invalid = await projectsFetch(new Request("http://localhost/projects", { + body: JSON.stringify({ name: "" }), + headers: { "content-type": "application/json" }, + method: "POST", + })); + expect(invalid.status).toBe(400); + + state.projects.push({ createdAt: now(), description: null, id: "proj-other", instructions: null, name: "Outro", updatedAt: now(), userId: "user-2" }); + const forbidden = await projectsFetch(new Request("http://localhost/projects/proj-other")); + expect(forbidden.status).toBe(404); + }); + + it("context aplica cap por arquivo (20k) e total (60k)", async () => { + seedFile("proj-1", "a.pdf", "x".repeat(25_000)); + seedFile("proj-1", "b.pdf", "y".repeat(25_000)); + seedFile("proj-1", "c.pdf", "z".repeat(25_000)); + seedFile("proj-1", "sem-texto.pdf", null); + + const response = await projectsFetch(new Request("http://localhost/projects/proj-1/context")); + expect(response.status).toBe(200); + const payload = await response.json() as { + instructions: string | null; + knowledge: Array<{ fileName: string; text: string }>; + }; + + expect(payload.instructions).toBe("Sempre responda em pt-BR"); + expect(payload.knowledge.map((entry) => entry.fileName)).toEqual(["a.pdf", "b.pdf", "c.pdf"]); + expect(payload.knowledge[0]?.text.length).toBe(20_000); + expect(payload.knowledge[1]?.text.length).toBe(20_000); + expect(payload.knowledge[2]?.text.length).toBe(20_000); + const total = payload.knowledge.reduce((sum, entry) => sum + entry.text.length, 0); + expect(total).toBe(60_000); + }); + + it("refresh snapshota o canvas como nova versão; mismatch devolve 409", async () => { + state.canvases.push({ + activeVersion: 2, + content: "conteúdo atualizado", + conversationId: "conv-1", + id: "canvas-9", + kind: "markdown", + language: null, + title: "Documento", + userId: "user-1", + }); + const artifact = seedArtifact("proj-1", "canvas-9", "conteúdo original"); + + const mismatch = await projectsFetch(new Request(`http://localhost/projects/proj-1/artifacts/${artifact.id}/refresh`, { + body: JSON.stringify({ canvasId: "canvas-outro" }), + headers: { "content-type": "application/json" }, + method: "POST", + })); + expect(mismatch.status).toBe(409); + + const refresh = await projectsFetch(new Request(`http://localhost/projects/proj-1/artifacts/${artifact.id}/refresh`, { + body: JSON.stringify({ canvasId: "canvas-9" }), + headers: { "content-type": "application/json" }, + method: "POST", + })); + expect(refresh.status).toBe(200); + const payload = await refresh.json() as { artifact: { content: string; currentVersion: number; versions: Array<{ version: number }> } }; + expect(payload.artifact.currentVersion).toBe(2); + expect(payload.artifact.content).toBe("conteúdo atualizado"); + expect(payload.artifact.versions[0]?.version).toBe(2); + }); + + it("deleta projeto em cascata (conversas, arquivos, artefatos)", async () => { + state.conversations.push({ archived: false, createdAt: now(), id: "conv-1", projectId: "proj-1", title: "Chat", updatedAt: now(), userId: "user-1" }); + seedFile("proj-1", "doc.pdf", "texto"); + seedArtifact("proj-1", "canvas-1", "conteúdo"); + + const response = await projectsFetch(new Request("http://localhost/projects/proj-1", { method: "DELETE" })); + expect(response.status).toBe(200); + expect(state.projects).toHaveLength(0); + expect(state.conversations).toHaveLength(0); + expect(state.files).toHaveLength(0); + expect(state.artifacts).toHaveLength(0); + }); +});