From bca116ce662368114a7013f6fffdbc2bf5950e11 Mon Sep 17 00:00:00 2001 From: Haoqing Wang <1506751656@qq.com> Date: Sat, 18 Apr 2026 12:22:03 +0800 Subject: [PATCH 1/2] 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 | 2 + apps/desktop/src/preload/index.ts | 9 + apps/desktop/src/renderer/src/App.tsx | 3 +- .../src/components/PreviewToolbar.tsx | 95 ++++++++++ apps/desktop/src/renderer/src/store.ts | 50 +++++- 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, 749 insertions(+), 59 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 1c5c12ff..0b9f20f6 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -14,6 +14,7 @@ "dependencies": { "@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 f23cae02..797664d0 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -5,6 +5,7 @@ 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'; +import { registerExporterIpc } from './exporter-ipc'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -78,6 +79,7 @@ function setupAutoUpdater(): void { void app.whenReady().then(() => { registerIpcHandlers(); + registerExporterIpc(() => mainWindow); setupAutoUpdater(); createWindow(); diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 1c32bf40..eaeaf510 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -1,6 +1,13 @@ import type { ChatMessage, ModelRef } from '@open-codesign/shared'; import { contextBridge, ipcRenderer } from 'electron'; +export type ExportFormat = 'html' | 'pdf' | 'pptx' | 'zip'; +export interface ExportInvokeResponse { + status: 'saved' | 'cancelled'; + path?: string; + bytes?: number; +} + const api = { detectProvider: (key: string) => ipcRenderer.invoke('codesign:detect-provider', key) as Promise, @@ -11,6 +18,8 @@ const api = { apiKey: string; 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 e761430c..71809e40 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -3,6 +3,7 @@ import { BUILTIN_DEMOS } from '@open-codesign/templates'; import { Button } from '@open-codesign/ui'; import { Send, Sparkles } from 'lucide-react'; import { useState } from 'react'; +import { PreviewToolbar } from './components/PreviewToolbar'; import { useCodesignStore } from './store'; export function App() { @@ -58,7 +59,6 @@ export function App() { ) : ( messages.map((m, i) => (
+
{previewHtml ? (