diff --git a/apps/desktop/src/main/design-system.ts b/apps/desktop/src/main/design-system.ts new file mode 100644 index 00000000..114892d6 --- /dev/null +++ b/apps/desktop/src/main/design-system.ts @@ -0,0 +1,229 @@ +import type { Dirent } from 'node:fs'; +import { readFile, readdir } from 'node:fs/promises'; +import { basename, extname, join, relative } from 'node:path'; +import { + STORED_DESIGN_SYSTEM_SCHEMA_VERSION, + type StoredDesignSystem, +} from '@open-codesign/shared'; + +const IGNORED_DIRS = new Set([ + '.git', + '.idea', + '.next', + '.turbo', + '.vscode', + 'build', + 'coverage', + 'dist', + 'node_modules', + 'out', +]); + +const CANDIDATE_EXTS = new Set([ + '.css', + '.scss', + '.sass', + '.less', + '.js', + '.jsx', + '.ts', + '.tsx', + '.json', + '.md', +]); + +const PRIORITY_PATTERNS = [ + /tailwind\.config/i, + /tokens?/i, + /theme/i, + /brand/i, + /palette/i, + /typography/i, + /global\.(css|scss|sass|less)$/i, + /variables?\.(css|scss|sass|less)$/i, +]; + +const MAX_FILES = 160; +const MAX_SELECTED_FILES = 12; +const MAX_FILE_CHARS = 32_000; + +interface CandidateFile { + fullPath: string; + relativePath: string; + score: number; +} + +function pushUnique(target: string[], value: string, max: number): void { + if (!value || target.includes(value) || target.length >= max) return; + target.push(value); +} + +function cleanValue(value: string): string { + return value + .replace(/\s+/g, ' ') + .replace(/["'`,]/g, '') + .trim(); +} + +function scoreCandidate(relativePath: string): number { + const fileName = basename(relativePath); + let score = 1; + for (const pattern of PRIORITY_PATTERNS) { + if (pattern.test(relativePath) || pattern.test(fileName)) score += 20; + } + if (/\.(css|scss|sass|less)$/i.test(fileName)) score += 8; + if (/tailwind/i.test(relativePath)) score += 8; + if (/src\//i.test(relativePath)) score += 4; + return score; +} + +async function collectCandidateFiles( + rootPath: string, + dirPath: string, + files: CandidateFile[], +): Promise { + if (files.length >= MAX_FILES) return; + + let entries: Dirent[]; + try { + entries = await readdir(dirPath, { withFileTypes: true, encoding: 'utf8' }); + } catch { + return; + } + + for (const entry of entries) { + if (files.length >= MAX_FILES) return; + const fullPath = join(dirPath, entry.name); + if (entry.isDirectory()) { + if (!IGNORED_DIRS.has(entry.name)) { + await collectCandidateFiles(rootPath, fullPath, files); + } + continue; + } + if (!entry.isFile()) continue; + const extension = extname(entry.name).toLowerCase(); + if (!CANDIDATE_EXTS.has(extension) && !/tailwind\.config/i.test(entry.name)) continue; + const relativePath = relative(rootPath, fullPath).replace(/\\/g, '/'); + files.push({ fullPath, relativePath, score: scoreCandidate(relativePath) }); + } +} + +function collectCssVarValues( + raw: string, + colors: string[], + spacing: string[], + radius: string[], + shadows: string[], +): void { + for (const match of raw.matchAll(/--([a-z0-9-]+)\s*:\s*([^;}{\n]+)/gi)) { + const name = match[1]?.toLowerCase() ?? ''; + const value = cleanValue(match[2] ?? ''); + if (!value) continue; + if (/color|accent|surface|text|brand|primary|secondary/.test(name)) + pushUnique(colors, value, 24); + if (/space|spacing|gap|padding|margin/.test(name)) pushUnique(spacing, value, 16); + if (/radius|rounded/.test(name)) pushUnique(radius, value, 16); + if (/shadow/.test(name)) pushUnique(shadows, value, 16); + } +} + +function collectLooseValues( + raw: string, + colors: string[], + fonts: string[], + spacing: string[], + radius: string[], + shadows: string[], +): void { + for (const match of raw.matchAll(/#[0-9a-f]{3,8}\b|rgba?\([^)]*\)|hsla?\([^)]*\)/gi)) { + pushUnique(colors, cleanValue(match[0] ?? ''), 24); + } + + for (const match of raw.matchAll(/(?:font-family|fontFamily)[^:=]*[:=]\s*([^\n;]+)/gi)) { + const value = cleanValue(match[1] ?? ''); + for (const part of value.split(',')) pushUnique(fonts, cleanValue(part), 16); + } + + for (const match of raw.matchAll( + /(?:spacing|space|gap|padding|margin)[^:=\n]*[:=]\s*([^\n,;]+)/gi, + )) { + pushUnique(spacing, cleanValue(match[1] ?? ''), 16); + } + + for (const match of raw.matchAll(/(?:radius|rounded|borderRadius)[^:=\n]*[:=]\s*([^\n,;]+)/gi)) { + pushUnique(radius, cleanValue(match[1] ?? ''), 16); + } + + for (const match of raw.matchAll(/(?:shadow|boxShadow)[^:=\n]*[:=]\s*([^\n,;]+)/gi)) { + pushUnique(shadows, cleanValue(match[1] ?? ''), 16); + } +} + +function buildSummary( + snapshot: Omit, +): string { + const repoLabel = basename(snapshot.rootPath); + const parts = [ + `Scanned ${snapshot.sourceFiles.length} likely design-system files under ${repoLabel}.`, + ]; + if (snapshot.colors.length > 0) + parts.push(`Color language: ${snapshot.colors.slice(0, 5).join(', ')}.`); + if (snapshot.fonts.length > 0) + parts.push(`Typography: ${snapshot.fonts.slice(0, 4).join(', ')}.`); + if (snapshot.spacing.length > 0) + parts.push(`Spacing cues: ${snapshot.spacing.slice(0, 4).join(', ')}.`); + if (snapshot.radius.length > 0) + parts.push(`Corner radius cues: ${snapshot.radius.slice(0, 4).join(', ')}.`); + if (snapshot.shadows.length > 0) + parts.push(`Shadow cues: ${snapshot.shadows.slice(0, 4).join(', ')}.`); + if (parts.length === 1) { + parts.push( + 'No strong structured tokens were extracted, so lean on the referenced styling files and keep the output conservative and cohesive.', + ); + } + return parts.join(' '); +} + +export async function scanDesignSystem(rootPath: string): Promise { + const candidates: CandidateFile[] = []; + await collectCandidateFiles(rootPath, rootPath, candidates); + + const selected = candidates + .sort((a, b) => b.score - a.score || a.relativePath.localeCompare(b.relativePath)) + .slice(0, MAX_SELECTED_FILES); + + const colors: string[] = []; + const fonts: string[] = []; + const spacing: string[] = []; + const radius: string[] = []; + const shadows: string[] = []; + + for (const file of selected) { + let raw = ''; + try { + raw = await readFile(file.fullPath, 'utf8'); + } catch { + continue; + } + const snippet = raw.slice(0, MAX_FILE_CHARS); + collectCssVarValues(snippet, colors, spacing, radius, shadows); + collectLooseValues(snippet, colors, fonts, spacing, radius, shadows); + } + + const baseSnapshot = { + rootPath, + sourceFiles: selected.map((file) => file.relativePath), + colors, + fonts, + spacing, + radius, + shadows, + }; + + return { + schemaVersion: STORED_DESIGN_SYSTEM_SCHEMA_VERSION, + ...baseSnapshot, + summary: buildSummary(baseSnapshot), + extractedAt: new Date().toISOString(), + }; +} diff --git a/apps/desktop/src/main/electron-runtime.ts b/apps/desktop/src/main/electron-runtime.ts new file mode 100644 index 00000000..07e60780 --- /dev/null +++ b/apps/desktop/src/main/electron-runtime.ts @@ -0,0 +1,7 @@ +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); + +export const electron = require('electron') as typeof import('electron'); + +export const { app, BrowserWindow, dialog, ipcMain, safeStorage, shell } = electron; diff --git a/apps/desktop/src/main/exporter-ipc.ts b/apps/desktop/src/main/exporter-ipc.ts index 53a06cd7..15b00eea 100644 --- a/apps/desktop/src/main/exporter-ipc.ts +++ b/apps/desktop/src/main/exporter-ipc.ts @@ -1,6 +1,7 @@ import { type ExporterFormat, exportArtifact } from '@open-codesign/exporters'; import { CodesignError } from '@open-codesign/shared'; -import { type BrowserWindow, dialog, ipcMain } from 'electron'; +import type { BrowserWindow } from 'electron'; +import { dialog, ipcMain } from './electron-runtime'; const FORMAT_FILTERS: Record = { html: [{ name: 'HTML', extensions: ['html'] }], diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 31db1530..33a94563 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -1,23 +1,30 @@ -import { dirname, join } from 'node:path'; +import { stat } from 'node:fs/promises'; +import { basename, dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { generate } from '@open-codesign/core'; +import { applyComment, generate } from '@open-codesign/core'; import { detectProviderFromKey } from '@open-codesign/providers'; -import { BRAND, CodesignError, GeneratePayload } from '@open-codesign/shared'; -import { BrowserWindow, app, ipcMain, shell } from 'electron'; +import { ApplyCommentPayload, BRAND, CodesignError, GeneratePayload } from '@open-codesign/shared'; +import type { BrowserWindow as ElectronBrowserWindow } from 'electron'; import { autoUpdater } from 'electron-updater'; +import { scanDesignSystem } from './design-system'; +import { BrowserWindow, app, dialog, ipcMain, shell } from './electron-runtime'; import { registerExporterIpc } from './exporter-ipc'; import { getLogPath, getLogger, initLogger } from './logger'; import { getApiKeyForProvider, getBaseUrlForProvider, + getCachedConfig, + getOnboardingState, loadConfigOnBoot, registerOnboardingIpc, + setDesignSystem, } from './onboarding-ipc'; +import { preparePromptContext } from './prompt-context'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -let mainWindow: BrowserWindow | null = null; +let mainWindow: ElectronBrowserWindow | null = null; function createWindow(): void { mainWindow = new BrowserWindow({ @@ -38,7 +45,7 @@ function createWindow(): void { mainWindow.on('ready-to-show', () => mainWindow?.show()); - mainWindow.webContents.setWindowOpenHandler(({ url }) => { + mainWindow.webContents.setWindowOpenHandler(({ url }: { url: string }) => { void shell.openExternal(url); return { action: 'deny' }; }); @@ -60,18 +67,79 @@ function registerIpcHandlers(): void { return detectProviderFromKey(key); }); + ipcMain.handle('codesign:pick-input-files', async () => { + const result = mainWindow + ? await dialog.showOpenDialog(mainWindow, { + properties: ['openFile', 'multiSelections'], + }) + : await dialog.showOpenDialog({ + properties: ['openFile', 'multiSelections'], + }); + if (result.canceled || result.filePaths.length === 0) return []; + return Promise.all( + result.filePaths.map(async (path) => { + try { + const info = await stat(path); + return { path, name: basename(path), size: info.size }; + } catch { + return { path, name: basename(path), size: 0 }; + } + }), + ); + }); + + ipcMain.handle('codesign:pick-design-system-directory', async () => { + const result = mainWindow + ? await dialog.showOpenDialog(mainWindow, { + properties: ['openDirectory'], + }) + : await dialog.showOpenDialog({ + properties: ['openDirectory'], + }); + if (result.canceled || result.filePaths.length === 0) return getOnboardingState(); + const rootPath = result.filePaths[0]; + if (!rootPath) return getOnboardingState(); + logIpc.info('designSystem.scan.start', { rootPath }); + const snapshot = await scanDesignSystem(rootPath); + const nextState = await setDesignSystem(snapshot); + logIpc.info('designSystem.scan.ok', { + rootPath, + sourceFiles: snapshot.sourceFiles.length, + colors: snapshot.colors.length, + fonts: snapshot.fonts.length, + }); + return nextState; + }); + + ipcMain.handle('codesign:clear-design-system', async () => { + const nextState = await setDesignSystem(null); + logIpc.info('designSystem.clear'); + return nextState; + }); + ipcMain.handle('codesign:generate', async (_e, raw: unknown) => { const payload = GeneratePayload.parse(raw); const apiKey = getApiKeyForProvider(payload.model.provider); const storedBaseUrl = getBaseUrlForProvider(payload.model.provider); const baseUrl = payload.baseUrl ?? storedBaseUrl; + const cfg = getCachedConfig(); + const promptContext = await preparePromptContext({ + attachments: payload.attachments, + referenceUrl: payload.referenceUrl, + designSystem: cfg?.designSystem ?? null, + }); + logIpc.info('generate', { provider: payload.model.provider, modelId: payload.model.modelId, promptLen: payload.prompt.length, historyLen: payload.history.length, + attachmentCount: payload.attachments.length, + hasReferenceUrl: payload.referenceUrl !== undefined, + hasDesignSystem: promptContext.designSystem !== null, baseUrl: baseUrl ?? '', }); + const t0 = Date.now(); try { const result = await generate({ @@ -79,12 +147,15 @@ function registerIpcHandlers(): void { history: payload.history, model: payload.model, apiKey, + attachments: promptContext.attachments, + referenceUrl: promptContext.referenceUrl, + designSystem: promptContext.designSystem ?? null, ...(baseUrl !== undefined ? { baseUrl } : {}), }); logIpc.info('generate.ok', { ms: Date.now() - t0, - artifacts: (result as { artifacts?: unknown[] }).artifacts?.length ?? 0, - cost: (result as { costUsd?: number }).costUsd, + artifacts: result.artifacts.length, + cost: result.costUsd, }); return result; } catch (err) { @@ -100,6 +171,66 @@ function registerIpcHandlers(): void { } }); + ipcMain.handle('codesign:apply-comment', async (_e, raw: unknown) => { + const payload = ApplyCommentPayload.parse(raw); + const cfg = getCachedConfig(); + if (cfg === null) { + throw new CodesignError( + 'No configuration found. Complete onboarding first.', + 'CONFIG_MISSING', + ); + } + const model = payload.model ?? { provider: cfg.provider, modelId: cfg.modelFast }; + const apiKey = getApiKeyForProvider(model.provider); + const storedBaseUrl = getBaseUrlForProvider(model.provider); + const promptContext = await preparePromptContext({ + attachments: payload.attachments, + referenceUrl: payload.referenceUrl, + designSystem: cfg.designSystem ?? null, + }); + + logIpc.info('applyComment', { + provider: model.provider, + modelId: model.modelId, + selector: payload.selection.selector, + attachmentCount: payload.attachments.length, + hasReferenceUrl: payload.referenceUrl !== undefined, + hasDesignSystem: promptContext.designSystem !== null, + baseUrl: storedBaseUrl ?? '', + }); + + const t0 = Date.now(); + try { + const result = await applyComment({ + html: payload.html, + comment: payload.comment, + selection: payload.selection, + model, + apiKey, + attachments: promptContext.attachments, + referenceUrl: promptContext.referenceUrl, + designSystem: promptContext.designSystem ?? null, + ...(storedBaseUrl !== undefined ? { baseUrl: storedBaseUrl } : {}), + }); + logIpc.info('applyComment.ok', { + ms: Date.now() - t0, + artifacts: result.artifacts.length, + cost: result.costUsd, + }); + return result; + } catch (err) { + logIpc.error('applyComment.fail', { + ms: Date.now() - t0, + provider: model.provider, + modelId: model.modelId, + selector: payload.selection.selector, + message: err instanceof Error ? err.message : String(err), + code: err instanceof CodesignError ? err.code : undefined, + }); + throw err; + } + }); + ipcMain.handle('codesign:open-log-folder', async () => { await shell.openPath(getLogPath()); }); diff --git a/apps/desktop/src/main/keychain.ts b/apps/desktop/src/main/keychain.ts index b711ece0..f6ecdaf3 100644 --- a/apps/desktop/src/main/keychain.ts +++ b/apps/desktop/src/main/keychain.ts @@ -1,5 +1,5 @@ import { CodesignError } from '@open-codesign/shared'; -import { safeStorage } from 'electron'; +import { safeStorage } from './electron-runtime'; export function ensureKeychainAvailable(): void { if (!safeStorage.isEncryptionAvailable()) { diff --git a/apps/desktop/src/main/locale-ipc.ts b/apps/desktop/src/main/locale-ipc.ts index ddd1917f..1e664dee 100644 --- a/apps/desktop/src/main/locale-ipc.ts +++ b/apps/desktop/src/main/locale-ipc.ts @@ -14,7 +14,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; -import { app, ipcMain } from 'electron'; +import { app, ipcMain } from './electron-runtime'; const CONFIG_DIR = join(homedir(), '.config', 'open-codesign'); const LOCALE_FILE = join(CONFIG_DIR, 'locale.json'); diff --git a/apps/desktop/src/main/logger.ts b/apps/desktop/src/main/logger.ts index b5c0cd87..f0696043 100644 --- a/apps/desktop/src/main/logger.ts +++ b/apps/desktop/src/main/logger.ts @@ -1,6 +1,6 @@ import { join } from 'node:path'; -import { app } from 'electron'; import log from 'electron-log/main'; +import { app } from './electron-runtime'; /** * Centralized logger for the main + preload + renderer processes. diff --git a/apps/desktop/src/main/onboarding-ipc.ts b/apps/desktop/src/main/onboarding-ipc.ts index ff24ff98..d633bf6f 100644 --- a/apps/desktop/src/main/onboarding-ipc.ts +++ b/apps/desktop/src/main/onboarding-ipc.ts @@ -3,11 +3,13 @@ import { CodesignError, type Config, type OnboardingState, + StoredDesignSystem, + type StoredDesignSystem as StoredDesignSystemValue, type SupportedOnboardingProvider, isSupportedOnboardingProvider, } from '@open-codesign/shared'; -import { ipcMain } from 'electron'; import { readConfig, writeConfig } from './config'; +import { ipcMain } from './electron-runtime'; import { decryptSecret, encryptSecret } from './keychain'; interface SaveKeyInput { @@ -61,12 +63,26 @@ export function getBaseUrlForProvider(provider: string): string | undefined { return ref?.baseUrl; } -function toState(cfg: Config | null): OnboardingState { +export function toState(cfg: Config | null): OnboardingState { if (cfg === null) { - return { hasKey: false, provider: null, modelPrimary: null, modelFast: null, baseUrl: null }; + return { + hasKey: false, + provider: null, + modelPrimary: null, + modelFast: null, + baseUrl: null, + designSystem: null, + }; } if (!isSupportedOnboardingProvider(cfg.provider)) { - return { hasKey: false, provider: null, modelPrimary: null, modelFast: null, baseUrl: null }; + return { + hasKey: false, + provider: null, + modelPrimary: null, + modelFast: null, + baseUrl: null, + designSystem: cfg.designSystem ?? null, + }; } const ref = cfg.secrets[cfg.provider]; if (ref === undefined) { @@ -76,6 +92,7 @@ function toState(cfg: Config | null): OnboardingState { modelPrimary: null, modelFast: null, baseUrl: null, + designSystem: cfg.designSystem ?? null, }; } return { @@ -84,9 +101,37 @@ function toState(cfg: Config | null): OnboardingState { modelPrimary: cfg.modelPrimary, modelFast: cfg.modelFast, baseUrl: cfg.baseUrls?.[cfg.provider]?.baseUrl ?? null, + designSystem: cfg.designSystem ?? null, }; } +export function getOnboardingState(): OnboardingState { + return toState(getCachedConfig()); +} + +export async function setDesignSystem( + designSystem: StoredDesignSystemValue | null, +): Promise { + const cfg = getCachedConfig(); + if (cfg === null) { + throw new CodesignError( + 'Cannot save a design system before onboarding has completed.', + 'CONFIG_MISSING', + ); + } + const next: Config = { + ...cfg, + ...(designSystem ? { designSystem: StoredDesignSystem.parse(designSystem) } : {}), + }; + if (designSystem === null) { + next.designSystem = undefined; + } + await writeConfig(next); + cachedConfig = next; + configLoaded = true; + return toState(cachedConfig); +} + function parseSaveKey(raw: unknown): SaveKeyInput { if (typeof raw !== 'object' || raw === null) { throw new CodesignError('save-key expects an object payload', 'IPC_BAD_INPUT'); @@ -176,6 +221,7 @@ export function registerOnboardingIpc(): void { [input.provider]: { ciphertext }, }, baseUrls: nextBaseUrls, + ...(cachedConfig?.designSystem ? { designSystem: cachedConfig.designSystem } : {}), }; await writeConfig(next); cachedConfig = next; diff --git a/apps/desktop/src/main/prompt-context.test.ts b/apps/desktop/src/main/prompt-context.test.ts new file mode 100644 index 00000000..2fb9d318 --- /dev/null +++ b/apps/desktop/src/main/prompt-context.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { preparePromptContext } from './prompt-context'; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('preparePromptContext', () => { + it('throws a CodesignError when an attachment cannot be read', async () => { + await expect( + preparePromptContext({ + attachments: [{ path: 'Z:/missing/brief.md', name: 'brief.md', size: 12 }], + }), + ).rejects.toMatchObject({ + name: 'CodesignError', + code: 'ATTACHMENT_READ_FAILED', + }); + }); + + it('throws a CodesignError when an attachment is too large', async () => { + await expect( + preparePromptContext({ + attachments: [{ path: 'C:/repo/huge.txt', name: 'huge.txt', size: 300_000 }], + }), + ).rejects.toMatchObject({ + name: 'CodesignError', + code: 'ATTACHMENT_TOO_LARGE', + }); + }); + + it('throws a CodesignError for oversized reference responses', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue( + new Response('too big', { + status: 200, + headers: { + 'content-type': 'text/html; charset=utf-8', + 'content-length': '300000', + }, + }), + ), + ); + + await expect( + preparePromptContext({ + referenceUrl: 'https://example.com/reference', + }), + ).rejects.toMatchObject({ + name: 'CodesignError', + code: 'REFERENCE_URL_TOO_LARGE', + }); + }); +}); diff --git a/apps/desktop/src/main/prompt-context.ts b/apps/desktop/src/main/prompt-context.ts new file mode 100644 index 00000000..ad7f899c --- /dev/null +++ b/apps/desktop/src/main/prompt-context.ts @@ -0,0 +1,222 @@ +import { open } from 'node:fs/promises'; +import { extname } from 'node:path'; +import type { AttachmentContext, ReferenceUrlContext } from '@open-codesign/core'; +import { CodesignError, type LocalInputFile, type StoredDesignSystem } from '@open-codesign/shared'; + +const TEXT_EXTS = new Set([ + '.css', + '.csv', + '.html', + '.js', + '.json', + '.jsx', + '.less', + '.md', + '.mjs', + '.scss', + '.svg', + '.ts', + '.tsx', + '.txt', + '.xml', + '.yaml', + '.yml', +]); + +const MAX_ATTACHMENT_CHARS = 6_000; +const MAX_ATTACHMENT_BYTES = 256_000; +const MAX_URL_EXCERPT_CHARS = 1_200; +const MAX_URL_RESPONSE_BYTES = 256_000; +const REFERENCE_CONTENT_TYPES = ['text/html', 'application/xhtml+xml']; + +function cleanText(raw: string, maxChars: number): string { + return raw + .replace(/\r\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim() + .slice(0, maxChars); +} + +function stripHtml(raw: string): string { + return raw + .replace(//gi, ' ') + .replace(//gi, ' ') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function isProbablyText(buffer: Buffer, extension: string): boolean { + if (TEXT_EXTS.has(extension)) return true; + const probe = buffer.subarray(0, 512); + return !probe.includes(0); +} + +async function readAttachment(file: LocalInputFile): Promise { + const extension = extname(file.name).toLowerCase(); + if (file.size > MAX_ATTACHMENT_BYTES) { + throw new CodesignError( + `Attachment "${file.name}" is too large (${file.size} bytes).`, + 'ATTACHMENT_TOO_LARGE', + ); + } + + let buffer: Buffer; + let handle: Awaited> | null = null; + try { + handle = await open(file.path, 'r'); + const length = Math.max(1, Math.min(file.size || MAX_ATTACHMENT_BYTES, MAX_ATTACHMENT_BYTES)); + const readBuffer = Buffer.alloc(length); + const { bytesRead } = await handle.read(readBuffer, 0, readBuffer.length, 0); + buffer = readBuffer.subarray(0, bytesRead); + } catch (error) { + throw new CodesignError(`Failed to read attachment "${file.path}"`, 'ATTACHMENT_READ_FAILED', { + cause: error, + }); + } finally { + await handle?.close(); + } + + if (!isProbablyText(buffer, extension)) { + return { + name: file.name, + path: file.path, + note: `Binary or unsupported format (${extension || 'unknown'}). Use the filename as a hint, not quoted content.`, + }; + } + + return { + name: file.name, + path: file.path, + excerpt: cleanText(buffer.toString('utf8'), MAX_ATTACHMENT_CHARS), + note: + buffer.length > MAX_ATTACHMENT_CHARS + ? 'Excerpt truncated to the most relevant leading content.' + : undefined, + }; +} + +async function readResponseText(response: Response, url: string): Promise { + const contentLength = Number(response.headers.get('content-length') ?? 0); + if (Number.isFinite(contentLength) && contentLength > MAX_URL_RESPONSE_BYTES) { + throw new CodesignError( + `Reference URL response is too large (${contentLength} bytes) for ${url}`, + 'REFERENCE_URL_TOO_LARGE', + ); + } + + if (!response.body) { + const text = await response.text(); + if (Buffer.byteLength(text, 'utf8') > MAX_URL_RESPONSE_BYTES) { + throw new CodesignError( + `Reference URL response is too large for ${url}`, + 'REFERENCE_URL_TOO_LARGE', + ); + } + return text; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let totalBytes = 0; + let text = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + + totalBytes += value.byteLength; + if (totalBytes > MAX_URL_RESPONSE_BYTES) { + throw new CodesignError( + `Reference URL response is too large for ${url}`, + 'REFERENCE_URL_TOO_LARGE', + ); + } + + text += decoder.decode(value, { stream: true }); + } + text += decoder.decode(); + return text; + } finally { + reader.releaseLock(); + } +} + +async function inspectReferenceUrl(url: string): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 4_000); + try { + const response = await fetch(url, { + headers: { 'user-agent': 'open-codesign/0.0.0 (+local desktop app)' }, + signal: controller.signal, + }); + if (!response.ok) { + throw new CodesignError( + `Reference URL fetch failed (${response.status}) for ${url}`, + 'REFERENCE_URL_FETCH_FAILED', + ); + } + + const contentType = response.headers.get('content-type')?.toLowerCase() ?? ''; + if (!REFERENCE_CONTENT_TYPES.some((type) => contentType.includes(type))) { + throw new CodesignError( + `Unsupported reference URL content type "${contentType || 'unknown'}" for ${url}`, + 'REFERENCE_URL_UNSUPPORTED', + ); + } + + const html = await readResponseText(response, url); + const title = html.match(/]*>([\s\S]*?)<\/title>/i)?.[1]?.trim(); + const description = + html.match(/]+name=["']description["'][^>]+content=["']([^"']+)["']/i)?.[1] ?? + html.match(/]+property=["']og:description["'][^>]+content=["']([^"']+)["']/i)?.[1]; + + return { + url, + ...(title ? { title } : {}), + ...(description ? { description } : {}), + excerpt: cleanText(stripHtml(html), MAX_URL_EXCERPT_CHARS), + }; + } catch (error) { + if (error instanceof CodesignError) throw error; + const code = + error instanceof Error && error.name === 'AbortError' + ? 'REFERENCE_URL_FETCH_TIMEOUT' + : 'REFERENCE_URL_FETCH_FAILED'; + const message = + code === 'REFERENCE_URL_FETCH_TIMEOUT' + ? `Reference URL request timed out for ${url}` + : `Failed to fetch reference URL ${url}`; + throw new CodesignError(message, code, { cause: error }); + } finally { + clearTimeout(timer); + } +} + +export interface PreparedPromptContext { + designSystem: StoredDesignSystem | null; + attachments: AttachmentContext[]; + referenceUrl: ReferenceUrlContext | null; +} + +export async function preparePromptContext(input: { + attachments?: LocalInputFile[] | undefined; + referenceUrl?: string | undefined; + designSystem?: StoredDesignSystem | null | undefined; +}): Promise { + const attachments = await Promise.all( + (input.attachments ?? []).map((file) => readAttachment(file)), + ); + const referenceUrl = + typeof input.referenceUrl === 'string' && input.referenceUrl.trim().length > 0 + ? await inspectReferenceUrl(input.referenceUrl.trim()) + : null; + + return { + designSystem: input.designSystem ?? null, + attachments, + referenceUrl, + }; +} diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 6921b477..b55abefa 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -1,7 +1,9 @@ import type { ChatMessage, + LocalInputFile, ModelRef, OnboardingState, + SelectedElement, SupportedOnboardingProvider, } from '@open-codesign/shared'; import { contextBridge, ipcRenderer } from 'electron'; @@ -31,7 +33,23 @@ const api = { history: ChatMessage[]; model: ModelRef; baseUrl?: string; + referenceUrl?: string; + attachments?: LocalInputFile[]; }) => ipcRenderer.invoke('codesign:generate', payload), + applyComment: (payload: { + html: string; + comment: string; + selection: SelectedElement; + model?: ModelRef; + referenceUrl?: string; + attachments?: LocalInputFile[]; + }) => ipcRenderer.invoke('codesign:apply-comment', payload), + pickInputFiles: () => + ipcRenderer.invoke('codesign:pick-input-files') as Promise, + pickDesignSystemDirectory: () => + ipcRenderer.invoke('codesign:pick-design-system-directory') as Promise, + clearDesignSystem: () => + ipcRenderer.invoke('codesign:clear-design-system') as Promise, export: (payload: { format: ExportFormat; htmlContent: string; defaultFilename?: string }) => ipcRenderer.invoke('codesign:export', payload) as Promise, checkForUpdates: () => ipcRenderer.invoke('codesign:check-for-updates'), diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index 0bf1614a..7259f8ad 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -31,7 +31,7 @@ export function App() { function submit(): void { const trimmed = prompt.trim(); if (!trimmed || isGenerating) return; - void sendPrompt(trimmed); + void sendPrompt({ prompt: trimmed }); setPrompt(''); } @@ -45,7 +45,7 @@ export function App() { if (!ready) return; const trimmed = prompt.trim(); if (!trimmed || isGenerating) return; - void sendPrompt(trimmed); + void sendPrompt({ prompt: trimmed }); setPrompt(''); }, }, diff --git a/apps/desktop/src/renderer/src/components/CommandPalette.tsx b/apps/desktop/src/renderer/src/components/CommandPalette.tsx index 64503d51..8fbe6ee6 100644 --- a/apps/desktop/src/renderer/src/components/CommandPalette.tsx +++ b/apps/desktop/src/renderer/src/components/CommandPalette.tsx @@ -32,6 +32,8 @@ export function CommandPalette() { messages: [], previewHtml: null, errorMessage: null, + iframeErrors: [], + selectedElement: null, }); pushToast({ variant: 'info', title: 'Workspace cleared' }); }, diff --git a/apps/desktop/src/renderer/src/components/InlineCommentComposer.tsx b/apps/desktop/src/renderer/src/components/InlineCommentComposer.tsx new file mode 100644 index 00000000..007b4183 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/InlineCommentComposer.tsx @@ -0,0 +1,82 @@ +import type { SelectedElement } from '@open-codesign/shared'; +import { MessageSquareText, X } from 'lucide-react'; +import { useState } from 'react'; +import { useCodesignStore } from '../store'; + +export function InlineCommentComposer() { + const selectedElement = useCodesignStore((s) => s.selectedElement); + if (!selectedElement) return null; + return ( + + ); +} + +interface InlineCommentComposerCardProps { + selectedElement: SelectedElement; +} + +function InlineCommentComposerCard({ selectedElement }: InlineCommentComposerCardProps) { + const clearCanvasElement = useCodesignStore((s) => s.clearCanvasElement); + const applyInlineComment = useCodesignStore((s) => s.applyInlineComment); + const isGenerating = useCodesignStore((s) => s.isGenerating); + const [draft, setDraft] = useState(''); + + return ( +
+
+
+
+ + Comment on {selectedElement.tag} +
+

+ {selectedElement.selector} +

+
+ +
+ +
+

+ Clicked elements stay selected in the canvas. Describe the visual or content change you + want, and open-codesign will rewrite the artifact around that target. +

+