diff --git a/.dockerignore b/.dockerignore index e9ceda6..26ebad9 100644 --- a/.dockerignore +++ b/.dockerignore @@ -13,13 +13,14 @@ dist build coverage -# Environment files -.env -.env.local -.env.*.local -.env.development.local -.env.test.local -.env.production.local +# Environment files and local registry credentials +.env* +.npmrc +.yarnrc.yml +*.pem +*.key +*.p12 +*.pfx # Git .git @@ -40,6 +41,7 @@ Thumbs.db # Testing coverage .nyc_output +.playwright-cli *.lcov # Logs @@ -81,3 +83,4 @@ temp # Monorepo apps packages +manifest-main diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2dde9bd..6b30009 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,17 +31,11 @@ jobs: - name: Build run: pnpm build - env: - DATABASE_URL: postgresql://dummy:dummy@localhost:5432/dummy - DIRECT_URL: postgresql://dummy:dummy@localhost:5432/dummy - NEON_AUTH_BASE_URL: https://dummy.neon.tech - NEON_AUTH_COOKIE_SECRET: dummy-secret-32-characters-long - ENCRYPTION_KEY: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef - name: Generate Release Notes id: release_notes run: | - VERSION=${GITHUB_REF#refs/tags/} + VERSION=${GITHUB_REF_NAME#v} echo "version=$VERSION" >> $GITHUB_OUTPUT # Extrair notas do CHANGELOG.md @@ -86,7 +80,7 @@ jobs: type=raw,value=latest - name: Build and push - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: context: . push: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fa2297..378021c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ e este projeto adere ao [Semantic Versioning](https://semver.org/lang/pt-BR/). ## [Unreleased] +### Adicionado +- Projetos, canvases versionados e artefatos persistidos, com compartilhamento, histórico de versões e respectivas migrações de banco. +- Bastidores das respostas de chat com roteamento, fallback, TTFT, estimativa de tokens por segundo e notas em reações. + +### Segurança +- Dependências transitivas corrigidas para eliminar os advisories conhecidos dos grafos completo e de produção. + ### Planejado - Suporte a mais provedores (Perplexity, Together AI) - Sistema de plugins diff --git a/CLAUDE.md b/CLAUDE.md index 95f03f3..2fbf027 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -72,3 +72,13 @@ GitHub Actions runs lint, typecheck, test, and build in parallel on push/PR to m - 2-space indentation, single quotes, semicolons (Prettier configured in `.prettierrc`) - Prefer Server Components; use `"use client"` only when needed - Tests colocated with source files (e.g., `lib/chat-stream.test.ts`) + + + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c56eafb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,67 @@ +FROM node:22-bookworm-slim AS base + +ENV NEXT_TELEMETRY_DISABLED=1 \ + PUPPETEER_SKIP_DOWNLOAD=true + +WORKDIR /app + +RUN corepack enable \ + && corepack prepare pnpm@10.33.0 --activate + + +FROM base AS dependencies + +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN pnpm install --frozen-lockfile --ignore-scripts + + +FROM base AS production-dependencies + +ENV NODE_ENV=production + +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN pnpm install --prod --frozen-lockfile --ignore-scripts + + +FROM base AS builder + +ENV NODE_ENV=production + +COPY --from=dependencies /app/node_modules ./node_modules +COPY . . + +# Prisma 7 writes the client to generated/prisma. Generation does not require +# a live database; prisma.config.ts supplies a non-secret fallback URL here. +RUN pnpm prisma:generate \ + && pnpm build + + +FROM node:22-bookworm-slim AS runner + +ENV NODE_ENV=production \ + NEXT_TELEMETRY_DISABLED=1 \ + PUPPETEER_SKIP_DOWNLOAD=true \ + PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium \ + HOSTNAME=0.0.0.0 \ + PORT=3000 + +WORKDIR /app + +RUN apt-get update \ + && apt-get install --no-install-recommends -y ca-certificates chromium \ + && rm -rf /var/lib/apt/lists/* \ + && mkdir -p /app/logs \ + && chown node:node /app/logs + +COPY --from=production-dependencies --chown=node:node /app/node_modules ./node_modules +COPY --from=builder --chown=node:node /app/.next ./.next +COPY --from=builder --chown=node:node /app/generated ./generated +COPY --from=builder --chown=node:node /app/public ./public +COPY --from=builder --chown=node:node /app/next.config.ts ./next.config.ts +COPY --from=builder --chown=node:node /app/package.json ./package.json + +USER node + +EXPOSE 3000 + +CMD ["node_modules/.bin/next", "start"] diff --git a/README.md b/README.md index 9202ad4..4f8b1a6 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ CI Licença MIT Node.js >= 22 - Next.js 16.2 + Next.js 16.3 TypeScript 5 Hono 4 Prisma 7 @@ -56,6 +56,10 @@ Em vez de cada aplicação integrar vários provedores separadamente, o ModelHub Roteamento inteligente
Tiers por complexidade, overrides por tarefa e fallbacks automáticos. Anexos no chat
Suporte a imagens, PDFs e documentos. + + Canvas versionado
Edite, visualize, restaure e compartilhe conteúdo Markdown, código, HTML, React e Mermaid. + Projetos
Agrupe conversas, instruções, arquivos de conhecimento e artefatos reutilizáveis. + Catálogo dinâmico
Modelos locais e busca remota quando o provider suporta. Pronto para produção
Rate limit, cooldown, headers de segurança, CI e deploy na Vercel. @@ -200,6 +204,7 @@ O campo `model` segue o formato `provider/model`, por exemplo: | Rota | Descrição | |---|---| | `/chat` | Conversa com provedores configurados | +| `/projects` | Projetos, arquivos de conhecimento e artefatos de canvas | | `/setup` | Integrações e credenciais por provider | | `/dashboard` | API keys, uso, custos, logs e routing | | `/account` | Informações da conta | @@ -240,7 +245,7 @@ A aplicação usa duas camadas: O banco é PostgreSQL via Neon, acessado com Prisma 7 e `@prisma/adapter-neon`. -Modelos importantes: `User`, `ApiKey`, `ProviderCredential`, `Conversation`, `Message`, `ConversationAttachment`, `UsageLog`, `UserMemory` e `UserSettings`. +Modelos importantes: `User`, `ApiKey`, `ProviderCredential`, `Conversation`, `Message`, `ConversationAttachment`, `Project`, `ProjectFile`, `ProjectArtifact`, `Canvas`, `UsageLog`, `UserMemory` e `UserSettings`. Para mudanças de schema: diff --git a/README_EN.md b/README_EN.md index 9648ccb..42116a8 100644 --- a/README_EN.md +++ b/README_EN.md @@ -11,7 +11,7 @@ CI MIT License Node.js >= 22 - Next.js 16.2 + Next.js 16.3 TypeScript 5 Hono 4 Prisma 7 @@ -56,6 +56,10 @@ Instead of each application integrating multiple providers separately, ModelHub Smart routing
Tiers by complexity, per-task overrides, and automatic fallbacks. Chat attachments
Support for images, PDFs, and documents. + + Versioned canvas
Edit, preview, restore, and share Markdown, code, HTML, React, and Mermaid content. + Projects
Group conversations, instructions, knowledge files, and reusable artifacts. + Dynamic catalog
Local models and remote search when the provider supports it. Production-ready
Rate limiting, cooldown, security headers, CI, and Vercel deploy. @@ -200,6 +204,7 @@ The `model` field follows the `provider/model` format, for example: | Route | Description | |---|---| | `/chat` | Chat with configured providers | +| `/projects` | Projects, knowledge files, and canvas artifacts | | `/setup` | Integrations and credentials per provider | | `/dashboard` | API keys, usage, costs, logs, and routing | | `/account` | Account information | @@ -240,7 +245,7 @@ The application uses two layers: The database is PostgreSQL via Neon, accessed with Prisma 7 and `@prisma/adapter-neon`. -Key models: `User`, `ApiKey`, `ProviderCredential`, `Conversation`, `Message`, `ConversationAttachment`, `UsageLog`, `UserMemory`, and `UserSettings`. +Key models: `User`, `ApiKey`, `ProviderCredential`, `Conversation`, `Message`, `ConversationAttachment`, `Project`, `ProjectFile`, `ProjectArtifact`, `Canvas`, `UsageLog`, `UserMemory`, and `UserSettings`. For schema changes: diff --git a/app/(app)/projects/[id]/page.tsx b/app/(app)/projects/[id]/page.tsx index 81ab244..b43507e 100644 --- a/app/(app)/projects/[id]/page.tsx +++ b/app/(app)/projects/[id]/page.tsx @@ -1,9 +1,10 @@ import { ProjectDetailPage } from "@/components/projects/project-detail-page"; -export default function ProjectRoutePage({ +export default async function ProjectRoutePage({ params, }: { params: Promise<{ id: string }>; }) { - return ; + const { id } = await params; + return ; } diff --git a/app/accent-provider.tsx b/app/accent-provider.tsx index e61c8e2..e1710e6 100644 --- a/app/accent-provider.tsx +++ b/app/accent-provider.tsx @@ -1,6 +1,6 @@ "use client"; -import { createContext, useCallback, useContext, useEffect, useState } from "react"; +import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react"; import { apiJson } from "@/lib/api"; import { isValidAccentColor, type AccentColorId } from "@/lib/accent-colors"; @@ -30,20 +30,37 @@ function applyAccentToDocument(accent: AccentColorId | null) { */ export function AccentProvider({ children }: { children: React.ReactNode }) { const [accent, setAccentState] = useState(null); + const localChangeVersionRef = useRef(0); useEffect(() => { let cancelled = false; async function loadAccent() { + const loadVersion = localChangeVersionRef.current; + const locallyApplied = document.documentElement.getAttribute("data-accent"); + if (isValidAccentColor(locallyApplied)) { + setAccentState(locallyApplied); + } + try { const data = await apiJson<{ settings?: { accentColor?: string | null } }>( "/user/settings", ); - if (cancelled) return; - const stored = data.settings?.accentColor; - if (isValidAccentColor(stored)) { - setAccentState(stored); - applyAccentToDocument(stored); + if (cancelled || loadVersion !== localChangeVersionRef.current) return; + if (data.settings && "accentColor" in data.settings) { + const stored = data.settings.accentColor; + const next = isValidAccentColor(stored) ? stored : "default"; + setAccentState(next); + applyAccentToDocument(next); + try { + if (next === "default") { + window.localStorage.removeItem(ACCENT_STORAGE_KEY); + } else { + window.localStorage.setItem(ACCENT_STORAGE_KEY, next); + } + } catch { + // localStorage bloqueado: o valor do servidor ainda vale nesta aba. + } } } catch { // Sem sessão ou offline: mantém o que o script inline aplicou. @@ -57,6 +74,7 @@ export function AccentProvider({ children }: { children: React.ReactNode }) { }, []); const setAccent = useCallback((next: AccentColorId | null) => { + localChangeVersionRef.current += 1; setAccentState(next); applyAccentToDocument(next); try { diff --git a/app/layout.tsx b/app/layout.tsx index 126d926..79ddd0d 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata, Viewport } from "next"; import { Inter, JetBrains_Mono, Source_Serif_4 } from "next/font/google"; +import Script from "next/script"; import { Analytics } from "@vercel/analytics/next"; import { Providers } from "./providers"; @@ -55,7 +56,9 @@ export default function RootLayout({ > {/* Aplica o accent salvo antes da hidratação para evitar flash (issue #177). */} - ok", + ); + + expect(result.indexOf("Content-Security-Policy")).toBeLessThan(result.indexOf("

escape

";')).toBe( + 'const value = "<\\/script>

escape

";', + ); + }); + + it("compiles React imports into the preview require shim", () => { + const code = transform( + 'import React, { useState } from "react"; export default function App(){ const [n] = useState(1); return

{n}

}', + { + filename: "canvas.tsx", + plugins: ["transform-modules-commonjs"], + presets: ["react", "typescript"], + }, + ).code; + + expect(code).not.toMatch(/^\s*import\s/m); + expect(code).toContain('require("react")'); + }); +}); diff --git a/components/canvas/canvas-preview.tsx b/components/canvas/canvas-preview.tsx index a6dc9b4..bc24334 100644 --- a/components/canvas/canvas-preview.tsx +++ b/components/canvas/canvas-preview.tsx @@ -13,12 +13,41 @@ export type CanvasPreviewProps = { language?: string | null; }; +const SANDBOX_CSP = [ + "default-src 'none'", + "script-src 'unsafe-inline'", + "style-src 'unsafe-inline'", + "img-src data: blob:", + "font-src data:", + "media-src data: blob:", + "connect-src 'none'", + "frame-src 'none'", + "object-src 'none'", + "base-uri 'none'", + "form-action 'none'", +].join("; "); + +export function buildSandboxedHtmlDocument(content: string): string { + const policy = ``; + if (/]*)?>/i.test(content)) { + return content.replace(/]*)?>/i, (head) => `${head}${policy}`); + } + if (/]*)?>/i.test(content)) { + return content.replace(/]*)?>/i, (html) => `${html}${policy}`); + } + return `${policy}${content}`; +} + +export function escapeInlineScript(value: string): string { + return value.replace(/<\/script/gi, "<\\/script"); +} + export function CanvasPreview({ content, kind, language }: CanvasPreviewProps) { if (kind === "markdown") { return
; } if (kind === "html") { - return