From c65fc53c31e26d8ec458c8dd96fcdefc58f8de37 Mon Sep 17 00:00:00 2001 From: Haoqing Wang <1506751656@qq.com> Date: Sat, 18 Apr 2026 12:22:03 +0800 Subject: [PATCH] feat(core,exporters,desktop): end-to-end first demo + HTML export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the full prompt → artifact → preview → export loop so the README's "first demo" actually produces a design when an API key is provided. - packages/templates: externalize the design-generator system prompt as SYSTEM_PROMPTS.designGenerator with a sibling design-generator.md for reviewable diffs. Replaces the inline string previously hard-coded in packages/core. Prompt embeds the research-backed Claude Design rules (single artifact, Tailwind CDN, semantic HTML, CSS variable tokens, WCAG AA, no lorem ipsum). - packages/core: pull SYSTEM_PROMPTS.designGenerator from templates, collapse the duplicated artifact-extraction loop into a `collect()` helper, add generate.test.ts (mocks the providers boundary, asserts empty-prompt error, artifact extraction shape, system-prompt wiring). - packages/exporters: real exportHtml() that ensures a doctype, injects the Tailwind CDN tag if missing, stamps a generator meta/banner, and pretty-prints. PDF / PPTX / ZIP each throw CodesignError with code EXPORTER_NOT_READY ("ships in Phase 2") — no silent fallbacks (PRINCIPLES §10). Top-level exportArtifact() dispatches lazily so unused formats stay out of the cold-start bundle (PRINCIPLES §1). - apps/desktop: codesign:export IPC backed by Electron dialog showSaveDialog, validates payload via CodesignError, propagates Phase-2 errors loudly. Preload exposes window.codesign.export(). Store gains exportActive(format) + a toast slot. PreviewToolbar renders an Export dropdown with HTML enabled and PDF/PPTX/ZIP disabled with "Coming in Phase 2" tooltips. - TIER 1 / dev-only fallback: store reads VITE_OPEN_CODESIGN_DEV_KEY so the demo runs before wt/onboarding lands real keychain plumbing. Marked clearly for removal in the integration commit. - examples/calm-spaces: README documents the demo + expected behaviour + intentional loud failure modes. No new third-party dependencies. All UI uses var(--color-*) tokens. Acceptance test (manual): VITE_OPEN_CODESIGN_DEV_KEY=sk-ant-... \ pnpm --filter @open-codesign/desktop dev → click "Calm Spaces meditation app" → Send → iframe renders → Export → HTML → /tmp/out.html → open in browser → Export → PDF → toast "PDF export ships in Phase 2" Signed-off-by: Haoqing Wang <1506751656@qq.com> --- apps/desktop/package.json | 1 + apps/desktop/src/main/exporter-ipc.ts | 65 +++++++ apps/desktop/src/main/index.ts | 10 ++ apps/desktop/src/preload/index.ts | 13 ++ apps/desktop/src/renderer/src/App.tsx | 9 +- .../src/components/PreviewToolbar.tsx | 95 ++++++++++ apps/desktop/src/renderer/src/store.ts | 90 +++++++++- examples/calm-spaces/README.md | 51 ++++++ packages/core/package.json | 3 +- packages/core/src/generate.test.ts | 84 +++++++++ packages/core/src/index.ts | 68 +++----- packages/exporters/package.json | 1 + packages/exporters/src/html.ts | 163 ++++++++++++++++++ packages/exporters/src/index.ts | 46 +++-- packages/exporters/src/pdf.ts | 9 + packages/exporters/src/pptx.ts | 9 + packages/exporters/src/zip.ts | 9 + packages/templates/src/index.ts | 2 + .../templates/src/system/design-generator.md | 78 +++++++++ packages/templates/src/system/index.ts | 54 ++++++ pnpm-lock.yaml | 6 + 21 files changed, 811 insertions(+), 55 deletions(-) create mode 100644 apps/desktop/src/main/exporter-ipc.ts create mode 100644 apps/desktop/src/renderer/src/components/PreviewToolbar.tsx create mode 100644 examples/calm-spaces/README.md create mode 100644 packages/core/src/generate.test.ts create mode 100644 packages/exporters/src/html.ts create mode 100644 packages/exporters/src/pdf.ts create mode 100644 packages/exporters/src/pptx.ts create mode 100644 packages/exporters/src/zip.ts create mode 100644 packages/templates/src/system/design-generator.md create mode 100644 packages/templates/src/system/index.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 21ec5496..351a6ebb 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -15,6 +15,7 @@ "@iarna/toml": "^2.2.5", "@open-codesign/artifacts": "workspace:*", "@open-codesign/core": "workspace:*", + "@open-codesign/exporters": "workspace:*", "@open-codesign/providers": "workspace:*", "@open-codesign/runtime": "workspace:*", "@open-codesign/shared": "workspace:*", diff --git a/apps/desktop/src/main/exporter-ipc.ts b/apps/desktop/src/main/exporter-ipc.ts new file mode 100644 index 00000000..b0e03438 --- /dev/null +++ b/apps/desktop/src/main/exporter-ipc.ts @@ -0,0 +1,65 @@ +import { type ExporterFormat, exportArtifact } from '@open-codesign/exporters'; +import { CodesignError } from '@open-codesign/shared'; +import { type BrowserWindow, dialog, ipcMain } from 'electron'; + +const FORMAT_FILTERS: Record = { + html: [{ name: 'HTML', extensions: ['html'] }], + pdf: [{ name: 'PDF', extensions: ['pdf'] }], + pptx: [{ name: 'PowerPoint', extensions: ['pptx'] }], + zip: [{ name: 'ZIP archive', extensions: ['zip'] }], +}; + +export interface ExportRequest { + format: ExporterFormat; + htmlContent: string; + defaultFilename?: string; +} + +export interface ExportResponse { + status: 'saved' | 'cancelled'; + path?: string; + bytes?: number; +} + +function parseRequest(raw: unknown): ExportRequest { + if (raw === null || typeof raw !== 'object') { + throw new CodesignError('export expects an object payload', 'IPC_BAD_INPUT'); + } + const r = raw as Record; + const format = r['format']; + const html = r['htmlContent']; + const defaultFilename = r['defaultFilename']; + if (format !== 'html' && format !== 'pdf' && format !== 'pptx' && format !== 'zip') { + throw new CodesignError(`Unknown export format: ${String(format)}`, 'EXPORTER_UNKNOWN'); + } + if (typeof html !== 'string' || html.length === 0) { + throw new CodesignError('export requires non-empty htmlContent', 'IPC_BAD_INPUT'); + } + const out: ExportRequest = { format, htmlContent: html }; + if (typeof defaultFilename === 'string' && defaultFilename.length > 0) { + out.defaultFilename = defaultFilename; + } + return out; +} + +export function registerExporterIpc(getWindow: () => BrowserWindow | null): void { + ipcMain.handle('codesign:export', async (_evt, raw: unknown): Promise => { + const req = parseRequest(raw); + const win = getWindow(); + const opts: Electron.SaveDialogOptions = { + title: `Export design as ${req.format.toUpperCase()}`, + defaultPath: req.defaultFilename ?? `design.${req.format}`, + filters: FORMAT_FILTERS[req.format], + }; + const picked = win ? await dialog.showSaveDialog(win, opts) : await dialog.showSaveDialog(opts); + if (picked.canceled || !picked.filePath) { + return { status: 'cancelled' }; + } + + // Tier 1: HTML succeeds, others throw EXPORTER_NOT_READY. We deliberately + // do NOT swallow the error here — let it propagate so the renderer can + // surface it as a toast (PRINCIPLES §10). + const result = await exportArtifact(req.format, req.htmlContent, picked.filePath); + return { status: 'saved', path: result.path, bytes: result.bytes }; + }); +} diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 09f117f7..bdf1eff6 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -5,7 +5,12 @@ import { detectProviderFromKey } from '@open-codesign/providers'; import { BRAND, CodesignError, GeneratePayload } from '@open-codesign/shared'; import { BrowserWindow, app, ipcMain, shell } from 'electron'; import { autoUpdater } from 'electron-updater'; +<<<<<<< HEAD import { getApiKeyForProvider, loadConfigOnBoot, registerOnboardingIpc } from './onboarding-ipc'; +||||||| parent of bca116c (feat(core,exporters,desktop): end-to-end first demo + HTML export) +======= +import { registerExporterIpc } from './exporter-ipc'; +>>>>>>> bca116c (feat(core,exporters,desktop): end-to-end first demo + HTML export) const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -81,7 +86,12 @@ function setupAutoUpdater(): void { void app.whenReady().then(async () => { await loadConfigOnBoot(); registerIpcHandlers(); +<<<<<<< HEAD registerOnboardingIpc(); +||||||| parent of bca116c (feat(core,exporters,desktop): end-to-end first demo + HTML export) +======= + registerExporterIpc(() => mainWindow); +>>>>>>> bca116c (feat(core,exporters,desktop): end-to-end first demo + HTML export) setupAutoUpdater(); createWindow(); diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index a86afc68..45f66992 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -6,6 +6,7 @@ import type { } from '@open-codesign/shared'; import { contextBridge, ipcRenderer } from 'electron'; +<<<<<<< HEAD export interface ValidateKeyResult { ok: true; modelCount: number; @@ -16,6 +17,16 @@ export interface ValidateKeyError { message: string; } +||||||| parent of bca116c (feat(core,exporters,desktop): end-to-end first demo + HTML export) +======= +export type ExportFormat = 'html' | 'pdf' | 'pptx' | 'zip'; +export interface ExportInvokeResponse { + status: 'saved' | 'cancelled'; + path?: string; + bytes?: number; +} + +>>>>>>> bca116c (feat(core,exporters,desktop): end-to-end first demo + HTML export) const api = { detectProvider: (key: string) => ipcRenderer.invoke('codesign:detect-provider', key) as Promise, @@ -25,6 +36,8 @@ const api = { model: ModelRef; baseUrl?: string; }) => ipcRenderer.invoke('codesign:generate', payload), + export: (payload: { format: ExportFormat; htmlContent: string; defaultFilename?: string }) => + ipcRenderer.invoke('codesign:export', payload) as Promise, checkForUpdates: () => ipcRenderer.invoke('codesign:check-for-updates'), downloadUpdate: () => ipcRenderer.invoke('codesign:download-update'), installUpdate: () => ipcRenderer.invoke('codesign:install-update'), diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index 7385a6ed..76c2a389 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -2,8 +2,15 @@ import { buildSrcdoc } from '@open-codesign/runtime'; import { BUILTIN_DEMOS } from '@open-codesign/templates'; import { Button } from '@open-codesign/ui'; import { Send, Sparkles } from 'lucide-react'; +<<<<<<< HEAD import { useEffect, useState } from 'react'; import { Onboarding } from './onboarding'; +||||||| parent of bca116c (feat(core,exporters,desktop): end-to-end first demo + HTML export) +import { useState } from 'react'; +======= +import { useState } from 'react'; +import { PreviewToolbar } from './components/PreviewToolbar'; +>>>>>>> bca116c (feat(core,exporters,desktop): end-to-end first demo + HTML export) import { useCodesignStore } from './store'; export function App() { @@ -78,7 +85,6 @@ export function App() { ) : ( messages.map((m, i) => (
+
{previewHtml ? (