diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 00000000..2c146989 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,30 @@ +#!/bin/sh +# +# Pre-commit hook: read version from branch name (e.g. v0.5.0) +# and sync to all package.json files +# + +BRANCH=$(git rev-parse --abbrev-ref HEAD) + +# Extract version from branch name like v0.5.0, v1.2.3, release/v2.0.0 +VERSION=$(echo "$BRANCH" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+') + +if [ -z "$VERSION" ]; then + exit 0 +fi + +CHANGED=0 +for f in package.json apps/*/package.json packages/*/package.json; do + if [ -f "$f" ]; then + CURRENT=$(jq -r '.version' "$f") + if [ "$CURRENT" != "$VERSION" ]; then + jq --arg v "$VERSION" '.version=$v' "$f" > "$f.tmp" && mv "$f.tmp" "$f" + git add "$f" + CHANGED=1 + fi + fi +done + +if [ "$CHANGED" = "1" ]; then + echo "[version-sync] branch $BRANCH → $VERSION" +fi diff --git a/.github/workflows/build-electron.yml b/.github/workflows/build-electron.yml index e5774479..b6ad756d 100644 --- a/.github/workflows/build-electron.yml +++ b/.github/workflows/build-electron.yml @@ -58,7 +58,7 @@ jobs: run: bun run mcp:compile - name: Build Electron app - run: npx electron-builder --config electron-builder.yml ${{ matrix.build_args }} --publish never + run: npx electron-builder --config apps/desktop/electron-builder.yml ${{ matrix.build_args }} --publish never env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} CSC_LINK: ${{ secrets.CSC_LINK }} @@ -72,8 +72,8 @@ jobs: - name: Rename arm64 update metadata if: matrix.platform == 'mac-arm64' run: | - if [ -f dist-electron/latest-mac.yml ]; then - mv dist-electron/latest-mac.yml dist-electron/latest-mac-arm64.yml + if [ -f out/release/latest-mac.yml ]; then + mv out/release/latest-mac.yml out/release/latest-mac-arm64.yml fi - name: Upload artifacts @@ -81,14 +81,14 @@ jobs: with: name: electron-${{ matrix.platform }} path: | - dist-electron/*.dmg - dist-electron/*.zip - dist-electron/*.exe - dist-electron/*.AppImage - dist-electron/*.deb - dist-electron/latest*.yml - dist-electron/*.blockmap - !dist-electron/builder-debug.yml + out/release/*.dmg + out/release/*.zip + out/release/*.exe + out/release/*.AppImage + out/release/*.deb + out/release/latest*.yml + out/release/*.blockmap + !out/release/builder-debug.yml retention-days: 30 release: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e12bbf5f..15f60127 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,5 +51,5 @@ jobs: uses: actions/upload-artifact@v4 with: name: web-build - path: .output/ + path: out/web/ retention-days: 7 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index d98ec8aa..cf554795 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -36,6 +36,9 @@ jobs: - variant: copilot target: with-copilot suffix: '-copilot' + - variant: gemini + target: with-gemini + suffix: '-gemini' - variant: full target: full suffix: '-full' diff --git a/.gitignore b/.gitignore index e3f4c2a0..56c4a513 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,5 @@ node_modules .DS_Store -dist -dist-ssr *.local count.txt .env @@ -12,7 +10,11 @@ count.txt .vinxi __unconfig* todos.json +.openpencil-tmp/ + +# Build outputs +out/ +dist/ +dist-ssr/ electron-dist/ dist-electron/ -.openpencil-tmp/ -scripts/figma-* diff --git a/CLAUDE.md b/CLAUDE.md index 078e65f7..5f69f44e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,7 @@ # CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +Detailed module docs are in `packages/CLAUDE.md`, `apps/web/CLAUDE.md`, and `apps/desktop/CLAUDE.md` — loaded automatically when working in those directories. ## Commands @@ -11,13 +12,29 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - **Run a single test:** `bun --bun vitest run path/to/test.ts` - **Type check:** `npx tsc --noEmit` - **Install dependencies:** `bun install` +- **Bump version:** `bun run bump ` (syncs all package.json files) - **Electron dev:** `bun run electron:dev` (starts Vite + Electron together) -- **Electron compile:** `bun run electron:compile` (esbuild electron/ to electron-dist/) +- **Electron compile:** `bun run electron:compile` (esbuild electron/ to out/desktop/) - **Electron build:** `bun run electron:build` (full web build + compile + electron-builder package) ## Architecture -OpenPencil is an open-source vector design tool (alternative to Pencil.dev) with a Design-as-Code philosophy. Built as a **TanStack Start** full-stack React application with Bun runtime. Server API powered by **Nitro**. Also ships as an **Electron** desktop app for macOS, Windows, and Linux. +OpenPencil is an open-source vector design tool (alternative to Pencil.dev) with a Design-as-Code philosophy. Organized as a **Bun monorepo** with workspaces: + +```text +openpencil/ +├── apps/ +│ ├── web/ TanStack Start full-stack React app (Vite + Nitro) +│ └── desktop/ Electron desktop app (macOS, Windows, Linux) +├── packages/ +│ ├── pen-types/ Type definitions for PenDocument model +│ ├── pen-core/ Document tree ops, layout engine, variables, boolean ops, clone utilities +│ ├── pen-codegen/ Multi-platform code generators +│ ├── pen-figma/ Figma .fig file parser and converter +│ ├── pen-renderer/ Standalone CanvasKit/Skia renderer +│ └── pen-sdk/ Umbrella SDK (re-exports all packages) +└── .githooks/ Pre-commit version sync from branch name +``` **Key technologies:** React 19, CanvasKit/Skia WASM (canvas engine), Paper.js (boolean path operations), Zustand v5 (state management), TanStack Router (file-based routing), Tailwind CSS v4, shadcn/ui (UI primitives), Vite 7, Nitro (server), Electron 35 (desktop), TypeScript (strict mode). @@ -53,299 +70,40 @@ PenDocument └── children: PenNode[] (default/single-page fallback) ``` -- `document-store-pages.ts` — page CRUD actions: `addPage`, `removePage`, `renamePage`, `reorderPage`, `duplicatePage` -- `canvas-store.ts` — `activePageId` state, `setActivePageId` action -- `canvas-sync-utils.ts` — `forcePageResync()` triggers page-aware canvas re-sync -- `page-tabs.tsx` — tab bar UI for multi-page navigation with context menu - ### Design Variables Architecture -```text -PenDocument (source of truth) - ├── variables: Record ($color-1, $spacing-md, ...) - ├── themes: Record ({Theme-1: ["Default","Dark"]}) - └── children: PenNode[] (nodes with $variable refs) - │ - ┌──────────┴──────────┐ - ▼ ▼ - Canvas Sync Code Generation - resolveNodeForCanvas() $ref → var(--name) - $ref → concrete value CSS Variables block -``` - - **`$variable` references are preserved** in the document store (e.g. `$color-1` in fill color) -- `normalize-pen-file.ts` does NOT resolve `$refs` — only fixes format issues - `resolveNodeForCanvas()` resolves `$refs` on-the-fly before CanvasKit rendering - Code generators output `var(--name)` for `$ref` values - Multiple theme axes supported (e.g. Theme-1 with Light/Dark, Theme-2 with Compact/Comfortable) -- Each theme axis has variants; variables can have per-variant values (`ThemedValue[]`) ### MCP Layered Design Workflow -External LLMs (Claude Code, Codex, Gemini CLI, etc.) can generate designs via MCP using two approaches: +External LLMs (Claude Code, Codex, Gemini CLI, etc.) can generate designs via MCP: -**Single-shot** (existing): `batch_design` or `insert_node` — generate entire design in one call. Simple but lower fidelity for complex designs. - -**Layered** (new): Break generation into phases, each with focused context and per-section post-processing: - -```text -get_design_prompt(section="planning") → Load planning-specific guidelines - │ - ▼ -design_skeleton(rootFrame, sections) → Create root + section frames - │ Returns: section IDs, content width, - │ per-section guidelines, suggested roles - ▼ -design_content(sectionId, children) ×N → Populate each section independently - │ Runs: role resolution, icon resolution, - │ sanitization per section - ▼ -design_refine(rootId) → Full-tree validation + auto-fixes - Returns: fix report, layout snapshot -``` - -**`get_design_prompt` segmented retrieval**: Instead of loading the full ~8K char prompt at once, external LLMs can request focused subsets: -- `schema` — PenNode types, fill/stroke format -- `layout` — Flexbox layout engine rules -- `roles` — Semantic role listing with defaults -- `text` — Typography, CJK, copywriting rules -- `style` — Visual style policy, palette -- `icons` — Available icon names + usage -- `examples` — Design examples -- `guidelines` — General design tips -- `planning` — Design type detection, section decomposition, style guide template, layered workflow guide - -**`design_skeleton` section guidelines**: The tool generates context-specific content guidelines for each section based on its name/role (nav → navbar layout tips, hero → headline sizing, form → input width rules, etc.), reducing per-call cognitive load. - -### Key Modules - -- **`src/canvas/`** — Canvas engine (33 files + `skia/` subdir with 11 files): - - **`skia/`** — CanvasKit/Skia WASM renderer (primary canvas engine): - - `skia-canvas.tsx` — Main canvas React component (SkiaCanvas), mouse/keyboard event handling, drawing tools, select/drag/resize/rotate, marquee, pen tool, text editing overlay - - `skia-engine.ts` — Core rendering engine: `SkiaEngine` class, `syncFromDocument()`, viewport transform, node flattening with layout resolution, `SpatialIndex` integration, zoom/pan, dirty-flag rendering loop - - `skia-renderer.ts` — GPU-accelerated draw calls: rectangles, ellipses, text, paths, images, frames, groups, selection handles, guides, agent indicators - - `skia-init.ts` — CanvasKit WASM loader with CDN fallback - - `skia-hit-test.ts` — `SpatialIndex` for spatial queries: `hitTest()`, `searchRect()`, R-tree backed - - `skia-viewport.ts` — Viewport math: `screenToScene`, `sceneToScreen`, `viewportMatrix`, `zoomToPoint` - - `skia-paint-utils.ts` — Color parsing, gradient creation, text line wrapping for CanvasKit - - `skia-path-utils.ts` — SVG path `d` string to CanvasKit Path conversion - - `skia-image-loader.ts` — Async image loading and caching for CanvasKit - - `skia-overlays.ts` — Selection overlays, hover highlights, dimension labels - - `skia-pen-tool.ts` — Pen tool implementation for Skia: anchor points, control handles, path building - - **Legacy Fabric.js files** (retained for `zoomToFitContent` utility, pending removal): - - `fabric-canvas.tsx`, `use-fabric-canvas.ts`, `canvas-object-factory.ts`, `canvas-object-sync.ts`, `canvas-object-modified.ts`, `canvas-controls.ts` - - **Shared modules** (used by both Skia and legacy code): - - `canvas-sync-lock.ts` — Prevents circular sync loops - - `canvas-sync-utils.ts` — `forcePageResync()` utility for page-aware canvas re-sync - - `canvas-constants.ts` — Default colors, zoom limits, stroke widths - - `canvas-node-creator.ts` — `createNodeForTool`, `isDrawingTool` helpers - - `canvas-layout-engine.ts` — Auto-layout computation: `resolvePadding`, `getNodeWidth/Height`, `computeLayoutPositions`, `Padding` interface - - `canvas-text-measure.ts` — Text width/height estimation, CJK detection, `parseSizing` - - `canvas-zoom-cache.ts` — Bitmap caching during zoom/pan for performance - - `use-canvas-sync.ts` — PenDocument ↔ canvas sync, node flattening, variable resolution via `resolveNodeForCanvas()` - - `use-canvas-events.ts` — Drawing events, shape creation - - `use-canvas-viewport.ts` — Wheel zoom, space+drag panning, tool cursor switching - - `use-canvas-selection.ts` — Selection sync between canvas and store - - `use-canvas-hover.ts` — Hover state management - - `use-canvas-guides.ts` / `guide-utils.ts` — Smart alignment guides with snapping - - `pen-tool.ts` — Bezier pen tool (Fabric-based, Skia version in `skia/skia-pen-tool.ts`) - - `parent-child-transform.ts` — Propagates parent transforms to children proportionally - - `use-dimension-label.ts` / `use-frame-labels.ts` / `use-entered-frame-overlay.ts` / `use-layout-indicator.ts` — Visual overlays - - `insertion-indicator.ts` / `drag-into-layout.ts` / `drag-reparent.ts` / `layout-reorder.ts` — Drag-and-drop support - - `selection-context.ts` — Multi-select context management - - `agent-indicator.ts` / `use-agent-indicators.ts` — Agent visual indicators on canvas during concurrent AI generation -- **`src/variables/`** — Design variables system (2 files): - - `resolve-variables.ts` — Core resolution utilities: `resolveVariableRef`, `resolveNodeForCanvas`, `getDefaultTheme`, `isVariableRef`; resolves `$variable` references to concrete values for canvas rendering with circular reference guards - - `replace-refs.ts` — `replaceVariableRefsInTree`: recursively walk node tree to replace/resolve `$refs` when renaming or deleting variables (covers opacity, gap, padding, fills, strokes, effects, text) -- **`src/stores/`** — Zustand stores (9 files): - - `canvas-store.ts` — UI/tool/selection/viewport/clipboard/interaction state, `variablesPanelOpen` toggle, `activePageId`, `figmaImportDialogOpen` - - `document-store.ts` — PenDocument tree CRUD: `addNode`, `updateNode`, `removeNode`, `moveNode`, `reorderNode`, `duplicateNode`, `groupNodes`, `ungroupNode`, `toggleVisibility`, `toggleLock`, `scaleDescendantsInStore`, `rotateDescendantsInStore`, `getNodeById`, `getParentOf`, `getFlatNodes`, `isDescendantOf`; Variable CRUD: `setVariable`, `removeVariable`, `renameVariable`, `setThemes` (all with history support) - - `document-store-pages.ts` — Page actions extracted from document-store: `addPage`, `removePage`, `renamePage`, `reorderPage`, `duplicatePage` - - `document-tree-utils.ts` — Pure tree helpers extracted from document-store: `findNodeInTree`, `findParentInTree`, `removeNodeFromTree`, `updateNodeInTree`, `flattenNodes`, `insertNodeInTree`, `isDescendantOf`, `getNodeBounds`, `findClearX`, `scaleChildrenInPlace`, `rotateChildrenInPlace`, `createEmptyDocument`, `DEFAULT_FRAME_ID` - - `history-store.ts` — Undo/redo (max 300 states), batch mode for grouped operations - - `ai-store.ts` — Chat messages, streaming state, generated code, model selection, `pendingAttachments` for image uploads - - `agent-settings-store.ts` — AI provider config (Anthropic/OpenAI/OpenCode/Copilot), MCP CLI integrations (Claude Code, Codex CLI, Gemini CLI, OpenCode CLI, Kiro CLI, Copilot CLI), localStorage persistence - - `uikit-store.ts` — UIKit management: imported kits, component browser state (search, category filters), localStorage persistence - - `theme-preset-store.ts` — Theme preset management and persistence -- **`src/types/`** — Type system (9 files): - - `pen.ts` — PenDocument/PenNode (frame, group, rectangle, ellipse, line, polygon, path, text, image, ref), ContainerProps, `PenPage`; `PenDocument.variables`, `PenDocument.themes`, `PenDocument.pages` - - `canvas.ts` — ToolType (select, frame, rectangle, ellipse, line, polygon, path, text, hand), ViewportState, SelectionState, CanvasInteraction - - `styles.ts` — PenFill (solid, linear_gradient, radial_gradient), PenStroke, PenEffect (shadow, blur), BlendMode, StyledTextSegment - - `variables.ts` — `VariableDefinition` (type + value), `ThemedValue` (value per theme), `VariableValue` - - `uikit.ts` — UIKit, KitComponent, ComponentCategory types for reusable component organization and browsing - - `agent-settings.ts` — AI provider config types (`AIProviderType`: anthropic/openai/opencode/copilot, `AIProviderConfig`, `MCPCliIntegration`, `GroupedModel`) - - `electron.d.ts` — Electron IPC bridge types (file dialogs, save operations, updater: `UpdaterState`/`UpdaterStatus`, `getState`/`checkForUpdates`/`quitAndInstall`/`onStateChange`) - - `theme-preset.ts` — Type definitions for theme presets - - `opencode-sdk.d.ts` — Type declarations for @opencode-ai/sdk -- **`src/components/editor/`** — Editor UI (9 files): editor-layout, toolbar (with variables panel toggle), boolean-toolbar (contextual floating toolbar for union/subtract/intersect, shown when 2+ compatible shapes selected), tool-button, shape-tool-dropdown (rectangle/ellipse/line/path + icon picker + image import), top-bar (with `AgentStatusButton`), status-bar, page-tabs (multi-page navigation with context menu), update-ready-banner (Electron auto-updater notification) -- **`src/components/panels/`** — Panels (29 files): - - `layer-panel.tsx` / `layer-item.tsx` / `layer-context-menu.tsx` — Tree view with drag-and-drop reordering and drop-into-children (above/below/inside), visibility/lock toggles, context menu, rename - - `property-panel.tsx` — Unified property panel - - `fill-section.tsx` — Solid + gradient fill, variable picker integration for color binding - - `stroke-section.tsx` — Stroke color/width/dash, variable picker for stroke color binding - - `corner-radius-section.tsx` — Unified or 4-point corner radius - - `size-section.tsx` — Position, size, rotation - - `text-section.tsx` — Font, size, weight, spacing, alignment - - `text-layout-section.tsx` — Text node layout controls (auto/fixed-width/fixed-height modes, fill width/height) - - `icon-section.tsx` — Icon property panel section: current icon name, library dropdown, icon picker - - `effects-section.tsx` — Shadow and blur - - `export-section.tsx` — Per-layer export to PNG/SVG with scale options (1x/2x/3x) - - `layout-section.tsx` — Auto-layout (none/vertical/horizontal), gap, padding, justify, align; variable picker for gap/padding binding - - `layout-padding-section.tsx` — Extracted padding controls: single/axis/T-R-B-L modes with popover mode switcher - - `appearance-section.tsx` — Opacity, visibility, lock, flip; variable picker for opacity binding - - `ai-chat-panel.tsx` / `chat-message.tsx` — AI chat with markdown, design block collapse, apply design, image attachment upload (paperclip button, preview strip, 5MB/4-image limit) - - `ai-chat-handlers.ts` — `useChatHandlers` hook, `isDesignRequest`, `buildContextString` helpers extracted from ai-chat-panel - - `ai-chat-checklist.tsx` — `FixedChecklist` component for AI generation progress display - - `code-panel.tsx` — Code generation output (React/Tailwind, HTML/CSS, CSS Variables, Vue, Svelte, Flutter, SwiftUI, Compose, React Native) - - `image-section.tsx` — Image property panel section - - `node-preview-svg.tsx` — Node SVG preview component - - `right-panel.tsx` — Right panel container - - `component-browser-panel.tsx` / `component-browser-grid.tsx` / `component-browser-card.tsx` — Resizable floating panel for browsing, importing, and inserting UIKit components with category tabs and search - - `variables-panel.tsx` — Design variables management: theme axes as tabs, variant columns, resizable floating panel, add/rename/delete themes and variants - - `variable-row.tsx` — Individual variable row: type icon, editable name, per-theme-variant value cells (color picker, number input, text input), context menu -- **`src/components/shared/`** — Reusable UI (10 files): ColorPicker, NumberInput, SectionHeader, ExportDialog, SaveDialog, AgentSettingsDialog, IconPickerDialog, VariablePicker, FigmaImportDialog, LanguageSelector -- **`src/components/icons/`** — Provider/brand logos: ClaudeLogo, OpenAILogo, OpenCodeLogo, CopilotLogo, FigmaLogo -- **`src/components/ui/`** — shadcn/ui primitives: Button, Select, Separator, Slider, Switch, Toggle, Tooltip -- **`src/services/ai/`** — AI services (31 files + `role-definitions/` subdir with 9 files): - - `ai-service.ts` — Main AI chat API wrapper, model negotiation, provider selection - - `ai-prompts.ts` — System prompts for design generation, context building - - `ai-types.ts` — `ChatMessage` (with `attachments?: ChatAttachment[]`), `ChatAttachment` (id, name, mediaType, data, size), `AIDesignRequest`, `OrchestratorPlan`, streaming response types - - `ai-runtime-config.ts` — Configuration constants for AI timeouts, thinking modes, effort levels, prompt length limits - - `model-profiles.ts` — Model capability profiles: adapts thinking mode, effort, timeouts, prompt complexity per model tier (full/standard/basic) - - `agent-identity.ts` — Agent identity assignment (color, name) for concurrent multi-agent generation - - `design-generator.ts` — Top-level `generateDesign`/`generateDesignModification` with orchestrator fallback, re-exports from design-parser and design-canvas-ops - - `design-parser.ts` — Pure JSON/JSONL parsing: `extractJsonFromResponse`, `extractStreamingNodes`, `parseJsonlToTree`, node validation and scoring - - `design-canvas-ops.ts` — Canvas mutation operations: `insertStreamingNode`, `applyNodesToCanvas`, `upsertNodesToCanvas`, `animateNodesToCanvas`, generation state management, sanitization and heuristics - - `design-node-sanitization.ts` — Node cloning and merging utilities: `deepCloneNode`, `setNodeChildren`, `mergeNodeForProgressiveUpsert` (extracted from design-canvas-ops) - - `design-animation.ts` — Fade-in animation coordination for generated design nodes - - `design-validation.ts` — Post-generation screenshot validation using vision API to detect and auto-fix visual issues - - `design-validation-fixes.ts` — Auto-fix strategies for validation-detected issues - - `design-pre-validation.ts` — Pre-validation heuristics before vision API call - - `design-screenshot.ts` — Canvas screenshot capture for validation pipeline - - `design-type-presets.ts` — Design type detection and section presets (landing page, dashboard, mobile, etc.) - - `design-code-generator.ts` — AI-powered code generation from design nodes - - `design-code-prompts.ts` — Prompts for design-to-code generation - - `design-system-generator.ts` — Design system token extraction from generated designs - - `design-system-prompts.ts` — Prompts for design system generation - - `html-renderer.ts` — PenNode tree to HTML rendering for validation screenshots - - `visual-ref-orchestrator.ts` — Visual reference-based orchestration for image-guided generation - - `generation-utils.ts` — Pure utilities for text measurement, size/padding parsing, phone placeholder generation, color extraction - - `icon-resolver.ts` — Auto-resolves AI-generated icon path nodes by name to verified Lucide SVG paths - - `role-resolver.ts` — Registry-based system for applying role-specific defaults (button padding, card gaps) and tree post-pass fixes - - `role-definitions/` — Modular role definition files: index, content, display, interactive, layout, navigation, media, typography, table - - `orchestrator.ts` — Orchestrator entry point: `executeOrchestration`, `callOrchestrator`, plan parsing - - `orchestrator-sub-agent.ts` — Sub-agent execution: `executeSubAgentsSequentially`, `executeSubAgent`, prompt building, retry/fallback logic - - `orchestrator-progress.ts` — `emitProgress`, `buildFinalStepTags` for streaming progress updates - - `orchestrator-prompts.ts` — Ultra-lightweight orchestrator prompt for spatial decomposition - - `orchestrator-prompt-optimizer.ts` — Prompt preparation, compression, timeout calculation, fallback plan generation - - `context-optimizer.ts` — Chat history trimming, sliding window to prevent unbounded context growth -- **`src/services/figma/`** — Figma `.fig` file import pipeline (14 files): - - `fig-parser.ts` — Binary `.fig` file parser - - `figma-types.ts` — Figma internal type definitions - - `figma-node-mapper.ts` — Maps Figma nodes to PenNodes - - `figma-fill-mapper.ts` — Converts Figma fills to PenFill - - `figma-stroke-mapper.ts` — Converts Figma strokes to PenStroke - - `figma-effect-mapper.ts` — Converts Figma effects to PenEffect - - `figma-layout-mapper.ts` — Maps Figma auto-layout to PenNode layout props - - `figma-text-mapper.ts` — Converts Figma text styles - - `figma-vector-decoder.ts` — Decodes Figma vector geometry - - `figma-color-utils.ts` — Color space conversion utilities - - `figma-image-resolver.ts` — Resolves image blob references - - `figma-clipboard.ts` — Figma clipboard paste handling - - `figma-node-converters.ts` — Figma node conversion utilities - - `figma-tree-builder.ts` — Figma document tree building -- **`src/services/codegen/`** — Multi-platform code generators (9 files, output `var(--name)` for `$variable` refs): - - `react-generator.ts` — React + Tailwind CSS - - `html-generator.ts` — HTML + CSS - - `css-variables-generator.ts` — CSS Variables from design tokens - - `vue-generator.ts` — Vue 3 + CSS - - `svelte-generator.ts` — Svelte + CSS - - `flutter-generator.ts` — Flutter/Dart - - `swiftui-generator.ts` — SwiftUI - - `compose-generator.ts` — Android Jetpack Compose - - `react-native-generator.ts` — React Native -- **`src/hooks/`** — Hooks (5 files): - - `use-keyboard-shortcuts.ts` — Global keyboard event handling: tools, clipboard, undo/redo, save, select all, delete, arrow nudge, z-order, boolean operations (Cmd+Alt+U/S/I) - - `use-electron-menu.ts` — Electron native menu IPC listener: dispatches menu actions (new, open, save, save-as, undo, redo, etc.) to Zustand stores; also handles `onOpenFile` for `.op` file association - - `use-figma-paste.ts` — Handle Figma clipboard paste into canvas - - `use-file-drop.ts` — Handle file drag-and-drop onto canvas - - `use-mcp-sync.ts` — MCP live canvas synchronization hook -- **`src/lib/`** — Utility functions (`utils.ts` with `cn()` for class merging) -- **`src/uikit/`** — UI kit system (3 files + `kits/` subdir): - - `built-in-registry.ts` — Default built-in UIKit with standard UI components - - `kit-import-export.ts` — Import/export UIKits from .pen files with variable reference collection - - `kit-utils.ts` — UIKit utilities: extract components from documents, find reusable nodes, deep clone - - `kits/` — Default kit data: `default-kit.ts`, `default-kit-meta.ts` -- **`src/mcp/`** — MCP server integration (2 files + `tools/` and `utils/` subdirs): - - `server.ts` — MCP server entry point, tool registration (stdio + HTTP modes) - - `document-manager.ts` — MCP utility for reading, writing, and caching PenDocuments from disk; live canvas sync via Nitro API - - `tools/` — Individual MCP tool implementations: - - Core: `open-document.ts`, `batch-get.ts`, `get-selection.ts`, `batch-design.ts` (DSL operations), `node-crud.ts` (insert/update/delete/move/copy/replace) - - Layout: `snapshot-layout.ts`, `find-empty-space.ts`, `import-svg.ts` - - Variables: `variables.ts`, `theme-presets.ts` - - Pages: `pages.ts` (add/remove/rename/reorder/duplicate) - - Layered design: `design-prompt.ts` (segmented retrieval), `design-skeleton.ts`, `design-content.ts`, `design-refine.ts`, `layered-design-defs.ts` - - `utils/` — Shared utilities: `id.ts`, `node-operations.ts` (page-aware `getDocChildren`/`setDocChildren`), `sanitize.ts`, `svg-node-parser.ts` -- **`src/utils/`** — File operations (save/open .pen), export (PNG/SVG), node clone, pen file normalization (format fixes only, preserves `$variable` refs), SVG parser (import SVG to editable PenNodes), syntax highlight, boolean operations (union/subtract/intersect via Paper.js), `app-storage.ts` (application storage/persistence), `arc-path.ts` (SVG arc utilities), `theme-preset-io.ts` (theme preset file I/O) -- **`src/constants/`** — Application constants: `app.ts` (MCP default port, app-level config) -- **`src/i18n/`** — Internationalization setup and locale files -- **`server/api/ai/`** — Nitro server API (8 files): `chat.ts` (streaming SSE with thinking state, multimodal image attachments per provider), `generate.ts` (non-streaming generation), `connect-agent.ts` (Claude Code/Codex CLI/OpenCode/Copilot connection), `models.ts` (model definitions), `validate.ts` (vision-based post-generation validation), `mcp-install.ts` (MCP server install/uninstall into CLI tool configs; auto-detects `node` availability — if missing, falls back to HTTP URL config `{ type: "http", url }` and auto-starts the MCP HTTP server), `install-agent.ts` (agent installation endpoint), `icon.ts` (icon name → SVG path resolution via local Iconify sets). Supports Anthropic API key or Claude Agent SDK (local OAuth) as dual providers -- **`server/utils/`** — Server utilities (7 files): - - `resolve-claude-cli.ts` — Resolves standalone `claude` binary path (handles Nitro bundling issues with SDK's `import.meta.url`) - - `resolve-claude-agent-env.ts` — Builds Claude Agent SDK environment: merges `~/.claude/settings.json` env, validates `ANTHROPIC_CUSTOM_HEADERS`, handles auth token compat - - `opencode-client.ts` — Shared OpenCode client manager, reuses server on port 4096 with random port fallback - - `codex-client.ts` — Codex CLI client wrapper with JSON streaming, thinking mode support, timeout handling, optional `imageFiles` for vision queries - - `copilot-client.ts` — Resolves standalone `copilot` binary path to avoid Bun's `node:sqlite` incompatibility with bundled CLI - - `mcp-server-manager.ts` — MCP HTTP server lifecycle management: spawn/track/stop detached process, PID file persistence - - `mcp-sync-state.ts` — In-memory MCP state: current document/selection, SSE broadcast to connected clients - -### CanvasKit/Skia Architecture - -- **GPU-accelerated WASM rendering** — CanvasKit (Skia compiled to WASM) renders all canvas content via WebGL surface -- **SkiaEngine class** (`skia-engine.ts`) is the core: owns the render loop, viewport transforms, node flattening, and `SpatialIndex` for hit testing -- **Dirty-flag rendering** — `markDirty()` schedules a `requestAnimationFrame` redraw; no continuous rendering loop -- **Node flattening** — `syncFromDocument()` walks the PenDocument tree, resolves auto-layout positions via `canvas-layout-engine.ts`, and produces flat `RenderNode[]` with absolute coordinates -- **SpatialIndex** (`skia-hit-test.ts`) — R-tree backed spatial queries for `hitTest()` (click) and `searchRect()` (marquee selection) -- **Coordinate conversion** — `screenToScene()` / `sceneToScreen()` in `skia-viewport.ts` handle viewport ↔ scene transforms -- **Bitmap caching** — `canvas-zoom-cache.ts` caches rendered frames during zoom/pan for smoother interaction -- **Event handling** — all mouse/keyboard events are handled directly in `skia-canvas.tsx` (no Fabric abstractions) -- **Parent-child transforms** — nodes are flattened to absolute coordinates; `parent-child-transform.ts` propagates transforms to descendants during drag/scale/rotate -- **Legacy Fabric.js** — `fabric` package remains as a dependency for `zoomToFitContent` utility; canvas rendering no longer uses Fabric - -### Routing - -File-based routing via TanStack Router. Routes in `src/routes/`, auto-generated tree in `src/routeTree.gen.ts` (do not edit). - -- `/` — Landing page -- `/editor` — Main design editor +- **Single-shot**: `batch_design` or `insert_node` — one call +- **Layered**: `design_skeleton` → `design_content` × N → `design_refine` — phased generation with focused context +- **Segmented prompts**: `get_design_prompt(section=...)` loads focused subsets (schema, layout, roles, icons, etc.) ### Path Aliases -`@/*` maps to `./src/*` (configured in `tsconfig.json` and `vite.config.ts`). +`@/*` maps to `./src/*` (configured in `apps/web/tsconfig.json` and `apps/web/vite.config.ts`). ### Styling -Tailwind CSS v4 imported via `src/styles.css`. UI primitives from shadcn/ui (`src/components/ui/`). Icons from `lucide-react`. shadcn/ui config in `components.json`. - -### Electron Desktop App - -- **`electron/main.ts`** — Main process: window creation, Nitro server fork, IPC for native file dialogs, `.op` file association handling (`open-file` event on macOS, CLI args + single-instance lock on Windows/Linux) -- **`electron/preload.ts`** — Context bridge for renderer ↔ main IPC (file dialogs, menu actions, updater state, `onOpenFile`/`readFile` for file association) -- **`electron/app-menu.ts`** — Native application menu configuration (File, Edit, View, Help) -- **`electron/auto-updater.ts`** — Auto-updater implementation: checks GitHub Releases on startup and periodically -- **`electron/constants.ts`** — Electron-specific constants -- **`electron/logger.ts`** — Main process logging -- **`electron-builder.yml`** — Packaging config: macOS (dmg/zip), Windows (nsis/portable), Linux (AppImage/deb), `.op` file association (`fileAssociations`) -- **`scripts/electron-dev.ts`** — Dev workflow: starts Vite → waits for port 3000 → compiles electron/ with esbuild → launches Electron -- Build flow: `BUILD_TARGET=electron bun run build` → `bun run electron:compile` → `npx electron-builder` -- In production, Nitro server is forked as a child process on a random port; Electron loads `http://127.0.0.1:{port}/editor` -- Auto-updater checks GitHub Releases on startup and every hour; `update-ready-banner.tsx` shows download progress and "Restart & Install" prompt -- **File association:** `.op` files are registered as OpenPencil documents via `fileAssociations` in `electron-builder.yml`. On macOS the `open-file` app event handles double-click/drag; on Windows/Linux `requestSingleInstanceLock` + `second-instance` event forwards CLI args to the existing window. Pending file paths are queued until the renderer is ready, then sent via `file:open` IPC channel. The renderer (`use-electron-menu.ts`) listens via `onOpenFile`, reads the file through `file:read` IPC, and calls `loadDocument`. +Tailwind CSS v4 imported via `apps/web/src/styles.css`. UI primitives from shadcn/ui. Icons from `lucide-react`. ### CI / CD -- **`.github/workflows/ci.yml`** — Push/PR: type check (`tsc --noEmit`), tests (`vitest`), web build -- **`.github/workflows/build-electron.yml`** — Tag push (`v*`) or manual: builds Electron for macOS, Windows, Linux in parallel, creates draft GitHub Release with all artifacts +- **`.github/workflows/ci.yml`** — Push/PR: type check, tests, web build +- **`.github/workflows/build-electron.yml`** — Tag push (`v*`) or manual: builds Electron for all platforms, creates draft GitHub Release +- **`.github/workflows/docker.yml`** — Docker image build and push + +### Version Sync + +- **Pre-commit hook** (`.githooks/pre-commit`): extracts version from branch name (e.g. `v0.5.0` → `0.5.0`) and syncs to all `package.json` files +- **Manual bump:** `bun run bump ` to set a specific version across all workspaces +- Requires `git config core.hooksPath .githooks` (one-time setup per clone) ## Code Style @@ -357,34 +115,13 @@ Tailwind CSS v4 imported via `src/styles.css`. UI primitives from shadcn/ui (`sr ## Git Commit Convention -Use [Conventional Commits](https://www.conventionalcommits.org/) format: +Use [Conventional Commits](https://www.conventionalcommits.org/) format: `(): ` -``` -(): +**Types:** `feat`, `fix`, `refactor`, `perf`, `style`, `docs`, `test`, `chore` - -``` +**Scopes:** `editor`, `canvas`, `panels`, `history`, `ai`, `codegen`, `store`, `types`, `variables`, `figma`, `mcp`, `electron`, `renderer`, `sdk` -### Type - -- `feat` — New feature -- `fix` — Bug fix -- `refactor` — Refactoring (no behavior change) -- `perf` — Performance optimization -- `style` — Code formatting (no logic change) -- `docs` — Documentation -- `test` — Tests -- `chore` — Build / tooling / dependency changes - -### Scope - -By module: `editor`, `canvas`, `panels`, `history`, `ai`, `codegen`, `store`, `types`, `variables`, `figma`, `mcp`, `electron`. - -### Rules - -- Subject in English, lowercase start, no period, imperative mood (e.g. `add`, `fix`, `remove`). -- Body is optional; explain **why** not what. -- One commit per change. Do not mix unrelated changes in a single commit. +**Rules:** Subject in English, lowercase start, no period, imperative mood. Body is optional; explain **why** not what. One commit per change. ## License diff --git a/Dockerfile b/Dockerfile index e699965e..0ad0bf89 100644 --- a/Dockerfile +++ b/Dockerfile @@ -58,12 +58,21 @@ ENV NODE_ENV=production NITRO_HOST=0.0.0.0 NITRO_PORT=3000 EXPOSE 3000 CMD ["bun", "run", "./.output/server/index.mjs"] +FROM oven/bun:1 AS with-gemini +WORKDIR /app +COPY --from=builder /app/.output ./.output +COPY --from=builder /app/package.json ./ +RUN bun install -g @anthropic-ai/gemini-cli +ENV NODE_ENV=production NITRO_HOST=0.0.0.0 NITRO_PORT=3000 +EXPOSE 3000 +CMD ["bun", "run", "./.output/server/index.mjs"] + # ── Full: all CLI tools ── FROM oven/bun:1 AS full WORKDIR /app COPY --from=builder /app/.output ./.output COPY --from=builder /app/package.json ./ -RUN bun install -g @anthropic-ai/claude-code @openai/codex opencode-ai @github/copilot +RUN bun install -g @anthropic-ai/claude-code @openai/codex opencode-ai @github/copilot @anthropic-ai/gemini-cli ENV NODE_ENV=production NITRO_HOST=0.0.0.0 NITRO_PORT=3000 EXPOSE 3000 CMD ["bun", "run", "./.output/server/index.mjs"] diff --git a/README.de.md b/README.de.md index cb33eebc..6a3120a0 100644 --- a/README.de.md +++ b/README.de.md @@ -1,5 +1,5 @@

- OpenPencil + OpenPencil

OpenPencil

@@ -10,7 +10,7 @@

- English · 简体中文 · 繁體中文 · 日本語 · 한국어 · Français · Español · Deutsch · Português · Русский · हिन्दी · Türkçe · ไทย · Tiếng Việt · Bahasa Indonesia + English · 简体中文 · 繁體中文 · 日本語 · 한국어 · Français · Español · Deutsch · Português · Русский · हिन्दी · Türkçe · ไทย · Tiếng Việt · Bahasa Indonesia

@@ -102,7 +102,7 @@ bun run electron:dev > **Voraussetzungen:** [Bun](https://bun.sh/) >= 1.0 und [Node.js](https://nodejs.org/) >= 18 -### Docker-Bereitstellung +### Docker Mehrere Image-Varianten sind verfügbar — wählen Sie die passende für Ihre Anforderungen: @@ -113,6 +113,7 @@ Mehrere Image-Varianten sind verfügbar — wählen Sie die passende für Ihre A | `openpencil-codex:latest` | — | + Codex CLI | | `openpencil-opencode:latest` | — | + OpenCode CLI | | `openpencil-copilot:latest` | — | + GitHub Copilot CLI | +| `openpencil-gemini:latest` | — | + Gemini CLI | | `openpencil-full:latest` | ~1 GB | Alle CLI-Tools | **Ausführen (nur Web):** @@ -121,9 +122,9 @@ Mehrere Image-Varianten sind verfügbar — wählen Sie die passende für Ihre A docker run -d -p 3000:3000 ghcr.io/zseven-w/openpencil:latest ``` -**Mit AI CLI ausführen (z.B. Claude Code):** +**Mit KI-CLI ausführen (z.B. Claude Code):** -Der AI-Chat basiert auf Claude CLI OAuth-Login. Verwenden Sie ein Docker-Volume, um die Login-Sitzung beizubehalten: +Der KI-Chat basiert auf Claude CLI OAuth-Login. Verwenden Sie ein Docker-Volume, um die Login-Sitzung beizubehalten: ```bash # Schritt 1 — Login (einmalig) @@ -167,6 +168,7 @@ docker build --target full -t openpencil-full . | **Codex CLI** | In den Agenteneinstellungen verbinden (`Cmd+,`) | | **OpenCode** | In den Agenteneinstellungen verbinden (`Cmd+,`) | | **GitHub Copilot** | `copilot login` dann in den Agenteneinstellungen verbinden (`Cmd+,`) | +| **Gemini CLI** | In den Agenteneinstellungen verbinden (`Cmd+,`) | **Modell-Fähigkeitsprofile** — passt Prompts, Thinking-Modus und Timeouts automatisch pro Modellstufe an. Modelle der Vollstufe (Claude) erhalten vollständige Prompts; Standardstufe (GPT-4o, Gemini, DeepSeek) deaktiviert Thinking; Basisstufe (MiniMax, Qwen, Llama, Mistral) erhält vereinfachte verschachtelte JSON-Prompts für maximale Zuverlässigkeit. @@ -213,7 +215,7 @@ docker build --target full -t openpencil-full . | --- | --- | | **Frontend** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui | | **Canvas** | CanvasKit/Skia (WASM, GPU-beschleunigt) | -| **Zustand** | Zustand v5 | +| **State** | Zustand v5 | | **Server** | Nitro | | **Desktop** | Electron 35 | | **KI** | Anthropic SDK · Claude Agent SDK · OpenCode SDK · Copilot SDK | @@ -223,22 +225,32 @@ docker build --target full -t openpencil-full . ## Projektstruktur ```text -src/ - canvas/ CanvasKit/Skia-Engine — Zeichnen, Synchronisierung, Layout, Hilfslinien, Stiftwerkzeug - components/ React-UI — Editor, Panels, gemeinsame Dialoge, Icons - services/ai/ KI-Chat, Orchestrierer, Designgenerierung, Streaming - services/figma/ Figma-.fig-Binär-Importpipeline - services/codegen React+Tailwind- und HTML+CSS-Codegeneratoren - stores/ Zustand — Canvas, Dokument, Seiten, Verlauf, KI, Einstellungen - variables/ Design-Token-Auflösung und Referenzverwaltung - mcp/ MCP-Server-Tools für externe CLI-Integration - uikit/ Wiederverwendbares Komponenten-Kit-System -server/ - api/ai/ Nitro-API — Streaming-Chat, Generierung, Validierung - utils/ Claude CLI, OpenCode, Codex, Copilot-Client-Wrapper -electron/ - main.ts Fenster, Nitro-Fork, natives Menü, Auto-Updater - preload.ts IPC-Brücke +openpencil/ +├── apps/ +│ ├── web/ TanStack Start Web-App +│ │ ├── src/ +│ │ │ ├── canvas/ CanvasKit/Skia-Engine — Zeichnen, Sync, Layout +│ │ │ ├── components/ React-UI — Editor, Panels, gemeinsame Dialoge, Icons +│ │ │ ├── services/ai/ KI-Chat, Orchestrierer, Designgenerierung, Streaming +│ │ │ ├── stores/ Zustand — Canvas, Dokument, Seiten, Verlauf, KI +│ │ │ ├── mcp/ MCP-Server-Tools für externe CLI-Integration +│ │ │ ├── hooks/ Tastaturkürzel, Datei-Drop, Figma-Paste +│ │ │ └── uikit/ Wiederverwendbares Komponenten-Kit-System +│ │ └── server/ +│ │ ├── api/ai/ Nitro-API — Streaming-Chat, Generierung, Validierung +│ │ └── utils/ Claude CLI, OpenCode, Codex, Copilot-Wrapper +│ └── desktop/ Electron-Desktop-App +│ ├── main.ts Fenster, Nitro-Fork, natives Menü, Auto-Updater +│ ├── ipc-handlers.ts Native Dateidialoge, Theme-Sync, Einstellungen-IPC +│ └── preload.ts IPC-Brücke +├── packages/ +│ ├── pen-types/ Typdefinitionen für das PenDocument-Modell +│ ├── pen-core/ Dokumentbaum-Operationen, Layout-Engine, Variablen +│ ├── pen-codegen/ Codegeneratoren (React, HTML, Vue, Flutter, ...) +│ ├── pen-figma/ Figma-.fig-Datei-Parser und -Konverter +│ ├── pen-renderer/ Eigenständiger CanvasKit/Skia-Renderer +│ └── pen-sdk/ Umbrella-SDK (re-exportiert alle Pakete) +└── .githooks/ Pre-Commit-Versionssynchronisierung vom Branch-Namen ``` ## Tastaturkürzel @@ -266,6 +278,7 @@ bun --bun run dev # Entwicklungsserver (Port 3000) bun --bun run build # Produktions-Build bun --bun run test # Tests ausführen (Vitest) npx tsc --noEmit # Typprüfung +bun run bump # Version über alle package.json synchronisieren bun run electron:dev # Electron-Entwicklung bun run electron:build # Electron-Paketierung ``` @@ -275,10 +288,11 @@ bun run electron:build # Electron-Paketierung Beiträge sind willkommen! Siehe [CLAUDE.md](./CLAUDE.md) für Architekturdetails und Code-Stil. 1. Forken und klonen -2. Branch erstellen: `git checkout -b feat/my-feature` -3. Prüfungen ausführen: `npx tsc --noEmit && bun --bun run test` -4. Mit [Conventional Commits](https://www.conventionalcommits.org/) committen: `feat(canvas): add rotation snapping` -5. Pull Request gegen `main` öffnen +2. Versionssynchronisierung einrichten: `git config core.hooksPath .githooks` +3. Branch erstellen: `git checkout -b feat/my-feature` +4. Prüfungen ausführen: `npx tsc --noEmit && bun --bun run test` +5. Mit [Conventional Commits](https://www.conventionalcommits.org/) committen: `feat(canvas): add rotation snapping` +6. Pull Request gegen `main` öffnen ## Roadmap @@ -290,6 +304,7 @@ Beiträge sind willkommen! Siehe [CLAUDE.md](./CLAUDE.md) für Architekturdetail - [x] Figma-`.fig`-Import - [x] Boolesche Operationen (Vereinigung, Subtraktion, Schnittmenge) - [x] Multi-Modell-Fähigkeitsprofile +- [x] Monorepo-Umstrukturierung mit wiederverwendbaren Paketen - [ ] Kollaboratives Bearbeiten - [ ] Plugin-System @@ -302,12 +317,11 @@ Beiträge sind willkommen! Siehe [CLAUDE.md](./CLAUDE.md) für Architekturdetail ## Community - Discord + Discord Unserem Discord beitreten — Fragen stellen, Designs teilen, Funktionen vorschlagen. - ## Star History diff --git a/README.es.md b/README.es.md index 46bd0566..5dfa99c5 100644 --- a/README.es.md +++ b/README.es.md @@ -1,5 +1,5 @@

- OpenPencil + OpenPencil

OpenPencil

@@ -10,7 +10,7 @@

- English · 简体中文 · 繁體中文 · 日本語 · 한국어 · Français · Español · Deutsch · Português · Русский · हिन्दी · Türkçe · ไทย · Tiếng Việt · Bahasa Indonesia + English · 简体中文 · 繁體中文 · 日本語 · 한국어 · Français · Español · Deutsch · Português · Русский · हिन्दी · Türkçe · ไทย · Tiếng Việt · Bahasa Indonesia

@@ -102,7 +102,7 @@ bun run electron:dev > **Requisitos previos:** [Bun](https://bun.sh/) >= 1.0 y [Node.js](https://nodejs.org/) >= 18 -### Despliegue con Docker +### Docker Hay varias variantes de imagen disponibles — elige la que se ajuste a tus necesidades: @@ -113,6 +113,7 @@ Hay varias variantes de imagen disponibles — elige la que se ajuste a tus nece | `openpencil-codex:latest` | — | + Codex CLI | | `openpencil-opencode:latest` | — | + OpenCode CLI | | `openpencil-copilot:latest` | — | + GitHub Copilot CLI | +| `openpencil-gemini:latest` | — | + Gemini CLI | | `openpencil-full:latest` | ~1 GB | Todas las herramientas CLI | **Ejecutar (solo web):** @@ -167,6 +168,7 @@ docker build --target full -t openpencil-full . | **Codex CLI** | Conectar en Configuración de Agente (`Cmd+,`) | | **OpenCode** | Conectar en Configuración de Agente (`Cmd+,`) | | **GitHub Copilot** | `copilot login` y luego conectar en Configuración de Agente (`Cmd+,`) | +| **Gemini CLI** | Conectar en Configuración de Agente (`Cmd+,`) | **Perfiles de Capacidad de Modelos** — adapta automáticamente los prompts, el modo de pensamiento y los tiempos de espera según el nivel del modelo. Los modelos de nivel completo (Claude) reciben prompts completos; los de nivel estándar (GPT-4o, Gemini, DeepSeek) desactivan el pensamiento; los de nivel básico (MiniMax, Qwen, Llama, Mistral) reciben prompts simplificados de JSON anidado para máxima fiabilidad. @@ -223,22 +225,32 @@ docker build --target full -t openpencil-full . ## Estructura del Proyecto ```text -src/ - canvas/ Motor CanvasKit/Skia — dibujo, sincronización, diseño, guías, herramienta pluma - components/ Interfaz React — editor, paneles, diálogos compartidos, iconos - services/ai/ Chat de IA, orquestador, generación de diseño, transmisión - services/figma/ Pipeline de importación binaria de Figma .fig - services/codegen Generadores de código React+Tailwind y HTML+CSS - stores/ Zustand — lienzo, documento, páginas, historial, IA, configuración - variables/ Resolución de tokens de diseño y gestión de referencias - mcp/ Herramientas del servidor MCP para integración con CLI externas - uikit/ Sistema de kit de componentes reutilizables -server/ - api/ai/ API Nitro — chat en streaming, generación, validación - utils/ Wrappers de cliente Claude CLI, OpenCode, Codex, Copilot -electron/ - main.ts Ventana, fork Nitro, menú nativo, actualizador automático - preload.ts Puente IPC +openpencil/ +├── apps/ +│ ├── web/ Aplicación web TanStack Start +│ │ ├── src/ +│ │ │ ├── canvas/ Motor CanvasKit/Skia — dibujo, sincronización, diseño +│ │ │ ├── components/ Interfaz React — editor, paneles, diálogos compartidos, iconos +│ │ │ ├── services/ai/ Chat de IA, orquestador, generación de diseño, transmisión +│ │ │ ├── stores/ Zustand — lienzo, documento, páginas, historial, IA +│ │ │ ├── mcp/ Herramientas del servidor MCP para integración con CLI externas +│ │ │ ├── hooks/ Atajos de teclado, soltar archivos, pegado de Figma +│ │ │ └── uikit/ Sistema de kit de componentes reutilizables +│ │ └── server/ +│ │ ├── api/ai/ API Nitro — chat en streaming, generación, validación +│ │ └── utils/ Wrappers de Claude CLI, OpenCode, Codex, Copilot +│ └── desktop/ Aplicación de escritorio Electron +│ ├── main.ts Ventana, fork Nitro, menú nativo, actualizador automático +│ ├── ipc-handlers.ts Diálogos de archivos nativos, sincronización de tema, preferencias IPC +│ └── preload.ts Puente IPC +├── packages/ +│ ├── pen-types/ Definiciones de tipos para el modelo PenDocument +│ ├── pen-core/ Operaciones de árbol del documento, motor de diseño, variables +│ ├── pen-codegen/ Generadores de código (React, HTML, Vue, Flutter, ...) +│ ├── pen-figma/ Parser y conversor de archivos .fig de Figma +│ ├── pen-renderer/ Renderizador independiente CanvasKit/Skia +│ └── pen-sdk/ SDK global (reexporta todos los paquetes) +└── .githooks/ Sincronización de versión pre-commit desde nombre de rama ``` ## Atajos de Teclado @@ -266,6 +278,7 @@ bun --bun run dev # Servidor de desarrollo (puerto 3000) bun --bun run build # Compilación de producción bun --bun run test # Ejecutar pruebas (Vitest) npx tsc --noEmit # Verificación de tipos +bun run bump # Sincronizar versión en todos los package.json bun run electron:dev # Desarrollo con Electron bun run electron:build # Empaquetado de Electron ``` @@ -275,10 +288,11 @@ bun run electron:build # Empaquetado de Electron ¡Las contribuciones son bienvenidas! Consulta [CLAUDE.md](./CLAUDE.md) para detalles sobre la arquitectura y el estilo de código. 1. Haz fork y clona el repositorio -2. Crea una rama: `git checkout -b feat/my-feature` -3. Ejecuta las verificaciones: `npx tsc --noEmit && bun --bun run test` -4. Haz commit con [Conventional Commits](https://www.conventionalcommits.org/): `feat(canvas): add rotation snapping` -5. Abre un PR contra `main` +2. Configura la sincronización de versión: `git config core.hooksPath .githooks` +3. Crea una rama: `git checkout -b feat/my-feature` +4. Ejecuta las verificaciones: `npx tsc --noEmit && bun --bun run test` +5. Haz commit con [Conventional Commits](https://www.conventionalcommits.org/): `feat(canvas): add rotation snapping` +6. Abre un PR contra `main` ## Hoja de Ruta @@ -290,6 +304,7 @@ bun run electron:build # Empaquetado de Electron - [x] Importación de Figma `.fig` - [x] Operaciones booleanas (unión, sustracción, intersección) - [x] Perfiles de capacidad multimodelo +- [x] Reestructuración en monorepo con paquetes reutilizables - [ ] Edición colaborativa - [ ] Sistema de plugins @@ -299,6 +314,13 @@ bun run electron:build # Empaquetado de Electron Contributors +## Comunidad + + + Discord + Únete a nuestro Discord + +— Haz preguntas, comparte diseños y sugiere funciones. ## Star History @@ -310,12 +332,6 @@ bun run electron:build # Empaquetado de Electron -## Comunidad - - - Discord - Únete a nuestro Discord - -— Haz preguntas, comparte diseños y sugiere funciones. - +## Licencia +[MIT](./LICENSE) — Copyright (c) 2026 ZSeven-W diff --git a/README.fr.md b/README.fr.md index ead36e36..250a18c7 100644 --- a/README.fr.md +++ b/README.fr.md @@ -1,5 +1,5 @@

- OpenPencil + OpenPencil

OpenPencil

@@ -10,7 +10,7 @@

- English · 简体中文 · 繁體中文 · 日本語 · 한국어 · Français · Español · Deutsch · Português · Русский · हिन्दी · Türkçe · ไทย · Tiếng Việt · Bahasa Indonesia + English · 简体中文 · 繁體中文 · 日本語 · 한국어 · Français · Español · Deutsch · Português · Русский · हिन्दी · Türkçe · ไทย · Tiếng Việt · Bahasa Indonesia

@@ -102,7 +102,7 @@ bun run electron:dev > **Prérequis :** [Bun](https://bun.sh/) >= 1.0 et [Node.js](https://nodejs.org/) >= 18 -### Déploiement Docker +### Docker Plusieurs variantes d'images sont disponibles — choisissez celle qui correspond à vos besoins : @@ -113,6 +113,7 @@ Plusieurs variantes d'images sont disponibles — choisissez celle qui correspon | `openpencil-codex:latest` | — | + Codex CLI | | `openpencil-opencode:latest` | — | + OpenCode CLI | | `openpencil-copilot:latest` | — | + GitHub Copilot CLI | +| `openpencil-gemini:latest` | — | + Gemini CLI | | `openpencil-full:latest` | ~1 Go | Tous les outils CLI | **Exécuter (web uniquement) :** @@ -167,12 +168,13 @@ docker build --target full -t openpencil-full . | **Codex CLI** | Connecter dans les Paramètres de l'agent (`Cmd+,`) | | **OpenCode** | Connecter dans les Paramètres de l'agent (`Cmd+,`) | | **GitHub Copilot** | `copilot login` puis connecter dans les Paramètres de l'agent (`Cmd+,`) | +| **Gemini CLI** | Connecter dans les Paramètres de l'agent (`Cmd+,`) | **Profils de capacités des modèles** — adapte automatiquement les prompts, le mode de réflexion et les délais d'attente par niveau de modèle. Les modèles de niveau complet (Claude) reçoivent des prompts complets ; le niveau standard (GPT-4o, Gemini, DeepSeek) désactive la réflexion ; le niveau basique (MiniMax, Qwen, Llama, Mistral) reçoit des prompts JSON imbriqués simplifiés pour une fiabilité maximale. **Serveur MCP** - Serveur MCP intégré — installation en un clic dans les CLI Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot -- Détection automatique de Node.js — si non installé, bascule automatiquement vers le transport HTTP et démarre le serveur MCP HTTP +- Détection automatique de Node.js — si non installé, bascule vers le transport HTTP et démarre automatiquement le serveur MCP HTTP - Automatisation du design depuis le terminal : lire, créer et modifier des fichiers `.op` via tout agent compatible MCP - **Workflow de design en couches** — `design_skeleton` → `design_content` → `design_refine` pour des designs multi-sections de plus haute fidélité - **Récupération segmentée des prompts** — chargez uniquement les connaissances de design nécessaires (schéma, layout, rôles, icônes, planification, etc.) @@ -223,22 +225,32 @@ docker build --target full -t openpencil-full . ## Structure du projet ```text -src/ - canvas/ Moteur CanvasKit/Skia — dessin, sync, mise en page, guides, outil plume - components/ Interface React — éditeur, panneaux, boîtes de dialogue partagées, icônes - services/ai/ Chat IA, orchestrateur, génération de design, streaming - services/figma/ Pipeline d'import binaire Figma .fig - services/codegen Générateurs de code React+Tailwind et HTML+CSS - stores/ Zustand — canevas, document, pages, historique, IA, paramètres - variables/ Résolution des tokens de design et gestion des références - mcp/ Outils serveur MCP pour l'intégration CLI externe - uikit/ Système de kits de composants réutilisables -server/ - api/ai/ API Nitro — chat en streaming, génération, validation - utils/ Enveloppes client Claude CLI, OpenCode, Codex, Copilot -electron/ - main.ts Fenêtre, fork Nitro, menu natif, mise à jour automatique - preload.ts Pont IPC +openpencil/ +├── apps/ +│ ├── web/ Application web TanStack Start +│ │ ├── src/ +│ │ │ ├── canvas/ Moteur CanvasKit/Skia — dessin, sync, mise en page +│ │ │ ├── components/ Interface React — éditeur, panneaux, boîtes de dialogue partagées, icônes +│ │ │ ├── services/ai/ Chat IA, orchestrateur, génération de design, streaming +│ │ │ ├── stores/ Zustand — canevas, document, pages, historique, IA +│ │ │ ├── mcp/ Outils serveur MCP pour l'intégration CLI externe +│ │ │ ├── hooks/ Raccourcis clavier, dépôt de fichiers, collage Figma +│ │ │ └── uikit/ Système de kits de composants réutilisables +│ │ └── server/ +│ │ ├── api/ai/ API Nitro — chat en streaming, génération, validation +│ │ └── utils/ Enveloppes Claude CLI, OpenCode, Codex, Copilot +│ └── desktop/ Application de bureau Electron +│ ├── main.ts Fenêtre, fork Nitro, menu natif, mise à jour automatique +│ ├── ipc-handlers.ts Dialogues fichiers natifs, sync thème, préférences IPC +│ └── preload.ts Pont IPC +├── packages/ +│ ├── pen-types/ Définitions de types pour le modèle PenDocument +│ ├── pen-core/ Opérations sur l'arbre du document, moteur de mise en page, variables +│ ├── pen-codegen/ Générateurs de code (React, HTML, Vue, Flutter, ...) +│ ├── pen-figma/ Parseur et convertisseur de fichiers Figma .fig +│ ├── pen-renderer/ Moteur de rendu CanvasKit/Skia autonome +│ └── pen-sdk/ SDK parapluie (réexporte tous les packages) +└── .githooks/ Synchronisation de version pre-commit depuis le nom de branche ``` ## Raccourcis clavier @@ -266,6 +278,7 @@ bun --bun run dev # Serveur de développement (port 3000) bun --bun run build # Build de production bun --bun run test # Lancer les tests (Vitest) npx tsc --noEmit # Vérification des types +bun run bump # Synchroniser la version dans tous les package.json bun run electron:dev # Développement Electron bun run electron:build # Packaging Electron ``` @@ -275,10 +288,11 @@ bun run electron:build # Packaging Electron Les contributions sont les bienvenues ! Consultez [CLAUDE.md](./CLAUDE.md) pour les détails d'architecture et le style de code. 1. Forker et cloner -2. Créer une branche : `git checkout -b feat/my-feature` -3. Exécuter les vérifications : `npx tsc --noEmit && bun --bun run test` -4. Commiter avec [Conventional Commits](https://www.conventionalcommits.org/) : `feat(canvas): add rotation snapping` -5. Ouvrir une PR contre `main` +2. Configurer la synchronisation de version : `git config core.hooksPath .githooks` +3. Créer une branche : `git checkout -b feat/my-feature` +4. Exécuter les vérifications : `npx tsc --noEmit && bun --bun run test` +5. Commiter avec [Conventional Commits](https://www.conventionalcommits.org/) : `feat(canvas): add rotation snapping` +6. Ouvrir une PR contre `main` ## Feuille de route @@ -290,6 +304,7 @@ Les contributions sont les bienvenues ! Consultez [CLAUDE.md](./CLAUDE.md) pour - [x] Import Figma `.fig` - [x] Opérations booléennes (union, soustraction, intersection) - [x] Profils de capacités multi-modèles +- [x] Restructuration en monorepo avec packages réutilisables - [ ] Édition collaborative - [ ] Système de plugins @@ -299,6 +314,13 @@ Les contributions sont les bienvenues ! Consultez [CLAUDE.md](./CLAUDE.md) pour Contributors +## Communauté + + + Discord + Rejoindre notre Discord + +— Posez des questions, partagez vos designs, suggérez des fonctionnalités. ## Star History @@ -310,12 +332,6 @@ Les contributions sont les bienvenues ! Consultez [CLAUDE.md](./CLAUDE.md) pour -## Communauté - - - Discord - Rejoindre notre Discord - -— Posez des questions, partagez vos designs, suggérez des fonctionnalités. - +## Licence +[MIT](./LICENSE) — Copyright (c) 2026 ZSeven-W diff --git a/README.hi.md b/README.hi.md index 7caecc97..dda30046 100644 --- a/README.hi.md +++ b/README.hi.md @@ -1,5 +1,5 @@

- OpenPencil + OpenPencil

OpenPencil

@@ -102,7 +102,7 @@ bun run electron:dev > **पूर्वापेक्षाएँ:** [Bun](https://bun.sh/) >= 1.0 और [Node.js](https://nodejs.org/) >= 18 -### Docker डिप्लॉयमेंट +### Docker कई इमेज वेरिएंट उपलब्ध हैं — अपनी ज़रूरत के अनुसार चुनें: @@ -113,6 +113,7 @@ bun run electron:dev | `openpencil-codex:latest` | — | + Codex CLI | | `openpencil-opencode:latest` | — | + OpenCode CLI | | `openpencil-copilot:latest` | — | + GitHub Copilot CLI | +| `openpencil-gemini:latest` | — | + Gemini CLI | | `openpencil-full:latest` | ~1 GB | सभी CLI टूल | **चलाएँ (केवल वेब):** @@ -167,6 +168,7 @@ docker build --target full -t openpencil-full . | **Codex CLI** | एजेंट सेटिंग्स में कनेक्ट करें (`Cmd+,`) | | **OpenCode** | एजेंट सेटिंग्स में कनेक्ट करें (`Cmd+,`) | | **GitHub Copilot** | `copilot login` फिर एजेंट सेटिंग्स में कनेक्ट करें (`Cmd+,`) | +| **Gemini CLI** | एजेंट सेटिंग्स में कनेक्ट करें (`Cmd+,`) | **मॉडल क्षमता प्रोफ़ाइल** — प्रत्येक मॉडल टियर के अनुसार प्रॉम्प्ट, थिंकिंग मोड और टाइमआउट को स्वचालित रूप से अनुकूलित करता है। फुल-टियर मॉडल (Claude) को पूर्ण प्रॉम्प्ट मिलते हैं; स्टैंडर्ड-टियर (GPT-4o, Gemini, DeepSeek) में थिंकिंग अक्षम होती है; बेसिक-टियर (MiniMax, Qwen, Llama, Mistral) को अधिकतम विश्वसनीयता के लिए सरलीकृत नेस्टेड-JSON प्रॉम्प्ट मिलते हैं। @@ -223,22 +225,32 @@ docker build --target full -t openpencil-full . ## प्रोजेक्ट संरचना ```text -src/ - canvas/ CanvasKit/Skia इंजन — ड्रॉइंग, सिंक, लेआउट, गाइड, पेन टूल - components/ React UI — एडिटर, पैनल, शेयर्ड डायलॉग, आइकन - services/ai/ AI चैट, ऑर्केस्ट्रेटर, डिज़ाइन जनरेशन, स्ट्रीमिंग - services/figma/ Figma .fig बाइनरी इम्पोर्ट पाइपलाइन - services/codegen React+Tailwind और HTML+CSS कोड जनरेटर - stores/ Zustand — कैनवास, दस्तावेज़, पेज, हिस्ट्री, AI, सेटिंग्स - variables/ डिज़ाइन टोकन रिज़ॉल्यूशन और रेफ़रेंस मैनेजमेंट - mcp/ बाहरी CLI इंटीग्रेशन के लिए MCP सर्वर टूल - uikit/ पुन: उपयोगी कम्पोनेंट किट सिस्टम -server/ - api/ai/ Nitro API — स्ट्रीमिंग चैट, जनरेशन, वैलिडेशन - utils/ Claude CLI, OpenCode, Codex, Copilot क्लाइंट रैपर -electron/ - main.ts विंडो, Nitro फ़ोर्क, नेटिव मेनू, ऑटो-अपडेटर - preload.ts IPC ब्रिज +openpencil/ +├── apps/ +│ ├── web/ TanStack Start वेब ऐप +│ │ ├── src/ +│ │ │ ├── canvas/ CanvasKit/Skia इंजन — ड्रॉइंग, सिंक, लेआउट +│ │ │ ├── components/ React UI — एडिटर, पैनल, शेयर्ड डायलॉग, आइकन +│ │ │ ├── services/ai/ AI चैट, ऑर्केस्ट्रेटर, डिज़ाइन जनरेशन, स्ट्रीमिंग +│ │ │ ├── stores/ Zustand — कैनवास, दस्तावेज़, पेज, हिस्ट्री, AI +│ │ │ ├── mcp/ बाहरी CLI इंटीग्रेशन के लिए MCP सर्वर टूल +│ │ │ ├── hooks/ कीबोर्ड शॉर्टकट, फ़ाइल ड्रॉप, Figma पेस्ट +│ │ │ └── uikit/ पुन: उपयोगी कम्पोनेंट किट सिस्टम +│ │ └── server/ +│ │ ├── api/ai/ Nitro API — स्ट्रीमिंग चैट, जनरेशन, वैलिडेशन +│ │ └── utils/ Claude CLI, OpenCode, Codex, Copilot रैपर +│ └── desktop/ Electron डेस्कटॉप ऐप +│ ├── main.ts विंडो, Nitro फ़ोर्क, नेटिव मेनू, ऑटो-अपडेटर +│ ├── ipc-handlers.ts नेटिव फ़ाइल डायलॉग, थीम सिंक, प्राथमिकताएँ IPC +│ └── preload.ts IPC ब्रिज +├── packages/ +│ ├── pen-types/ PenDocument मॉडल के लिए टाइप परिभाषाएँ +│ ├── pen-core/ दस्तावेज़ ट्री ऑपरेशन, लेआउट इंजन, वेरिएबल +│ ├── pen-codegen/ कोड जनरेटर (React, HTML, Vue, Flutter, ...) +│ ├── pen-figma/ Figma .fig फ़ाइल पार्सर और कनवर्टर +│ ├── pen-renderer/ स्टैंडअलोन CanvasKit/Skia रेंडरर +│ └── pen-sdk/ अम्ब्रेला SDK (सभी पैकेज री-एक्सपोर्ट) +└── .githooks/ ब्रांच नाम से प्री-कमिट वर्शन सिंक ``` ## कीबोर्ड शॉर्टकट @@ -266,6 +278,7 @@ bun --bun run dev # डेव सर्वर (पोर्ट 3000) bun --bun run build # प्रोडक्शन बिल्ड bun --bun run test # टेस्ट चलाएँ (Vitest) npx tsc --noEmit # टाइप चेक +bun run bump # सभी package.json में वर्शन सिंक करें bun run electron:dev # Electron डेव bun run electron:build # Electron पैकेज ``` @@ -275,10 +288,11 @@ bun run electron:build # Electron पैकेज योगदान का स्वागत है! आर्किटेक्चर विवरण और कोड स्टाइल के लिए [CLAUDE.md](./CLAUDE.md) देखें। 1. फ़ोर्क और क्लोन करें -2. ब्रांच बनाएँ: `git checkout -b feat/my-feature` -3. चेक चलाएँ: `npx tsc --noEmit && bun --bun run test` -4. [Conventional Commits](https://www.conventionalcommits.org/) के साथ कमिट करें: `feat(canvas): add rotation snapping` -5. `main` के विरुद्ध PR खोलें +2. वर्शन सिंक सेटअप करें: `git config core.hooksPath .githooks` +3. ब्रांच बनाएँ: `git checkout -b feat/my-feature` +4. चेक चलाएँ: `npx tsc --noEmit && bun --bun run test` +5. [Conventional Commits](https://www.conventionalcommits.org/) के साथ कमिट करें: `feat(canvas): add rotation snapping` +6. `main` के विरुद्ध PR खोलें ## रोडमैप @@ -290,6 +304,7 @@ bun run electron:build # Electron पैकेज - [x] Figma `.fig` इम्पोर्ट - [x] बूलियन ऑपरेशन (यूनियन, सबट्रैक्ट, इंटरसेक्ट) - [x] मल्टी-मॉडल क्षमता प्रोफ़ाइल +- [x] पुन: उपयोगी पैकेज के साथ मोनोरेपो पुनर्गठन - [ ] सहयोगी संपादन - [ ] प्लगइन सिस्टम @@ -302,12 +317,11 @@ bun run electron:build # Electron पैकेज ## समुदाय - Discord + Discord हमारे Discord में शामिल हों — प्रश्न पूछें, डिज़ाइन साझा करें, सुविधाएँ सुझाएँ। - ## Star History diff --git a/README.id.md b/README.id.md index d762d351..92581c7a 100644 --- a/README.id.md +++ b/README.id.md @@ -1,5 +1,5 @@

- OpenPencil + OpenPencil

OpenPencil

@@ -10,7 +10,7 @@

- English · 简体中文 · 繁體中文 · 日本語 · 한국어 · Français · Español · Deutsch · Português · Русский · हिन्दी · Türkçe · ไทย · Tiếng Việt · Bahasa Indonesia + English · 简体中文 · 繁體中文 · 日本語 · 한국어 · Français · Español · Deutsch · Português · Русский · हिन्दी · Türkçe · ไทย · Tiếng Việt · Bahasa Indonesia

@@ -102,7 +102,7 @@ bun run electron:dev > **Prasyarat:** [Bun](https://bun.sh/) >= 1.0 dan [Node.js](https://nodejs.org/) >= 18 -### Deployment Docker +### Docker Tersedia beberapa varian image — pilih yang sesuai kebutuhan Anda: @@ -113,6 +113,7 @@ Tersedia beberapa varian image — pilih yang sesuai kebutuhan Anda: | `openpencil-codex:latest` | — | + Codex CLI | | `openpencil-opencode:latest` | — | + OpenCode CLI | | `openpencil-copilot:latest` | — | + GitHub Copilot CLI | +| `openpencil-gemini:latest` | — | + Gemini CLI | | `openpencil-full:latest` | ~1 GB | Semua alat CLI | **Jalankan (hanya web):** @@ -167,6 +168,7 @@ docker build --target full -t openpencil-full . | **Codex CLI** | Hubungkan di Pengaturan Agen (`Cmd+,`) | | **OpenCode** | Hubungkan di Pengaturan Agen (`Cmd+,`) | | **GitHub Copilot** | `copilot login` lalu hubungkan di Pengaturan Agen (`Cmd+,`) | +| **Gemini CLI** | Hubungkan di Pengaturan Agen (`Cmd+,`) | **Profil Kemampuan Model** — secara otomatis menyesuaikan prompt, mode thinking, dan timeout per tingkatan model. Model tingkat penuh (Claude) mendapat prompt lengkap; tingkat standar (GPT-4o, Gemini, DeepSeek) menonaktifkan thinking; tingkat dasar (MiniMax, Qwen, Llama, Mistral) mendapat prompt JSON bertingkat yang disederhanakan untuk keandalan maksimum. @@ -223,22 +225,32 @@ docker build --target full -t openpencil-full . ## Struktur Proyek ```text -src/ - canvas/ Mesin CanvasKit/Skia — menggambar, sinkronisasi, tata letak, panduan, alat pen - components/ UI React — editor, panel, dialog bersama, ikon - services/ai/ Chat AI, orkestrator, pembuatan desain, streaming - services/figma/ Pipeline impor biner Figma .fig - services/codegen Generator kode React+Tailwind dan HTML+CSS - stores/ Zustand — kanvas, dokumen, halaman, riwayat, AI, pengaturan - variables/ Resolusi token desain dan manajemen referensi - mcp/ Alat server MCP untuk integrasi CLI eksternal - uikit/ Sistem kit komponen yang dapat digunakan ulang -server/ - api/ai/ Nitro API — chat streaming, pembuatan, validasi - utils/ Pembungkus klien Claude CLI, OpenCode, Codex, Copilot -electron/ - main.ts Jendela, fork Nitro, menu native, pembaruan otomatis - preload.ts Jembatan IPC +openpencil/ +├── apps/ +│ ├── web/ Aplikasi web TanStack Start +│ │ ├── src/ +│ │ │ ├── canvas/ Mesin CanvasKit/Skia — menggambar, sinkronisasi, tata letak +│ │ │ ├── components/ UI React — editor, panel, dialog bersama, ikon +│ │ │ ├── services/ai/ Chat AI, orkestrator, pembuatan desain, streaming +│ │ │ ├── stores/ Zustand — kanvas, dokumen, halaman, riwayat, AI +│ │ │ ├── mcp/ Alat server MCP untuk integrasi CLI eksternal +│ │ │ ├── hooks/ Pintasan keyboard, seret file, tempel Figma +│ │ │ └── uikit/ Sistem kit komponen yang dapat digunakan ulang +│ │ └── server/ +│ │ ├── api/ai/ Nitro API — chat streaming, pembuatan, validasi +│ │ └── utils/ Pembungkus Claude CLI, OpenCode, Codex, Copilot +│ └── desktop/ Aplikasi desktop Electron +│ ├── main.ts Jendela, fork Nitro, menu native, pembaruan otomatis +│ ├── ipc-handlers.ts Dialog file native, sinkronisasi tema, preferensi IPC +│ └── preload.ts Jembatan IPC +├── packages/ +│ ├── pen-types/ Definisi tipe untuk model PenDocument +│ ├── pen-core/ Operasi pohon dokumen, mesin tata letak, variabel +│ ├── pen-codegen/ Generator kode (React, HTML, Vue, Flutter, ...) +│ ├── pen-figma/ Parser dan konverter file Figma .fig +│ ├── pen-renderer/ Renderer CanvasKit/Skia mandiri +│ └── pen-sdk/ SDK payung (re-ekspor semua paket) +└── .githooks/ Pre-commit sinkronisasi versi dari nama branch ``` ## Pintasan Keyboard @@ -266,6 +278,7 @@ bun --bun run dev # Server pengembangan (port 3000) bun --bun run build # Build produksi bun --bun run test # Jalankan pengujian (Vitest) npx tsc --noEmit # Pemeriksaan tipe +bun run bump # Sinkronisasi versi di semua package.json bun run electron:dev # Pengembangan Electron bun run electron:build # Paket Electron ``` @@ -275,10 +288,11 @@ bun run electron:build # Paket Electron Kontribusi sangat disambut! Lihat [CLAUDE.md](./CLAUDE.md) untuk detail arsitektur dan gaya kode. 1. Fork dan clone -2. Buat cabang: `git checkout -b feat/my-feature` -3. Jalankan pemeriksaan: `npx tsc --noEmit && bun --bun run test` -4. Commit dengan [Conventional Commits](https://www.conventionalcommits.org/): `feat(canvas): add rotation snapping` -5. Buka PR ke `main` +2. Atur sinkronisasi versi: `git config core.hooksPath .githooks` +3. Buat cabang: `git checkout -b feat/my-feature` +4. Jalankan pemeriksaan: `npx tsc --noEmit && bun --bun run test` +5. Commit dengan [Conventional Commits](https://www.conventionalcommits.org/): `feat(canvas): add rotation snapping` +6. Buka PR ke `main` ## Peta Jalan @@ -290,6 +304,7 @@ Kontribusi sangat disambut! Lihat [CLAUDE.md](./CLAUDE.md) untuk detail arsitekt - [x] Impor Figma `.fig` - [x] Operasi boolean (gabung, kurangi, potong) - [x] Profil kemampuan multi-model +- [x] Restrukturisasi monorepo dengan paket yang dapat digunakan ulang - [ ] Pengeditan kolaboratif - [ ] Sistem plugin @@ -302,12 +317,11 @@ Kontribusi sangat disambut! Lihat [CLAUDE.md](./CLAUDE.md) untuk detail arsitekt ## Komunitas - Discord + Discord Bergabung dengan Discord kami — Ajukan pertanyaan, bagikan desain, sarankan fitur. - ## Star History diff --git a/README.ja.md b/README.ja.md index 1b9448f8..08d4f8d6 100644 --- a/README.ja.md +++ b/README.ja.md @@ -1,5 +1,5 @@

- OpenPencil + OpenPencil

OpenPencil

@@ -102,7 +102,7 @@ bun run electron:dev > **前提条件:** [Bun](https://bun.sh/) >= 1.0 および [Node.js](https://nodejs.org/) >= 18 -### Docker デプロイ +### Docker 複数のイメージバリアントが利用可能です — ニーズに合ったものを選択してください: @@ -113,6 +113,7 @@ bun run electron:dev | `openpencil-codex:latest` | — | + Codex CLI | | `openpencil-opencode:latest` | — | + OpenCode CLI | | `openpencil-copilot:latest` | — | + GitHub Copilot CLI | +| `openpencil-gemini:latest` | — | + Gemini CLI | | `openpencil-full:latest` | ~1 GB | すべての CLI ツール | **実行(Web のみ):** @@ -167,6 +168,7 @@ docker build --target full -t openpencil-full . | **Codex CLI** | エージェント設定で接続(`Cmd+,`) | | **OpenCode** | エージェント設定で接続(`Cmd+,`) | | **GitHub Copilot** | `copilot login` 後、エージェント設定で接続(`Cmd+,`) | +| **Gemini CLI** | エージェント設定で接続(`Cmd+,`) | **モデル能力プロファイル** — モデルの階層に応じてプロンプト、シンキングモード、タイムアウトを自動適応。フル階層モデル(Claude)には完全なプロンプト、標準階層(GPT-4o、Gemini、DeepSeek)ではシンキングを無効化、ベーシック階層(MiniMax、Qwen、Llama、Mistral)には最大限の信頼性のために簡略化されたネスト JSON プロンプトを使用。 @@ -194,7 +196,7 @@ docker build --target full -t openpencil-full . **デザインシステム** - デザイン変数 — カラー・数値・文字列トークン、`$variable` 参照付き -- マルチテーマサポート — 複数のテーマ軸、各軸に複数バリアント(ライト/ダーク、コンパクト/コンフォータブル) +- マルチテーマサポート — 複数のテーマ軸、各軸に複数バリアント(Light/Dark、Compact/Comfortable) - コンポーネントシステム — インスタンスとオーバーライドを持つ再利用可能なコンポーネント - CSS 同期 — カスタムプロパティの自動生成、コード出力に `var(--name)` を使用 @@ -223,22 +225,32 @@ docker build --target full -t openpencil-full . ## プロジェクト構成 ```text -src/ - canvas/ CanvasKit/Skia エンジン — 描画、同期、レイアウト、ガイド、ペンツール - components/ React UI — エディター、パネル、共有ダイアログ、アイコン - services/ai/ AI チャット、オーケストレーター、デザイン生成、ストリーミング - services/figma/ Figma .fig バイナリインポートパイプライン - services/codegen React+Tailwind および HTML+CSS コードジェネレーター - stores/ Zustand — キャンバス、ドキュメント、ページ、履歴、AI、設定 - variables/ デザイントークンの解決とリファレンス管理 - mcp/ 外部 CLI 統合用 MCP サーバーツール - uikit/ 再利用可能なコンポーネントキットシステム -server/ - api/ai/ Nitro API — ストリーミングチャット、生成、バリデーション - utils/ Claude CLI、OpenCode、Codex、Copilot クライアントラッパー -electron/ - main.ts ウィンドウ、Nitro フォーク、ネイティブメニュー、自動アップデーター - preload.ts IPC ブリッジ +openpencil/ +├── apps/ +│ ├── web/ TanStack Start Web アプリ +│ │ ├── src/ +│ │ │ ├── canvas/ CanvasKit/Skia エンジン — 描画、同期、レイアウト +│ │ │ ├── components/ React UI — エディター、パネル、共有ダイアログ、アイコン +│ │ │ ├── services/ai/ AI チャット、オーケストレーター、デザイン生成、ストリーミング +│ │ │ ├── stores/ Zustand — キャンバス、ドキュメント、ページ、履歴、AI +│ │ │ ├── mcp/ 外部 CLI 統合用 MCP サーバーツール +│ │ │ ├── hooks/ キーボードショートカット、ファイルドロップ、Figma ペースト +│ │ │ └── uikit/ 再利用可能なコンポーネントキットシステム +│ │ └── server/ +│ │ ├── api/ai/ Nitro API — ストリーミングチャット、生成、バリデーション +│ │ └── utils/ Claude CLI、OpenCode、Codex、Copilot ラッパー +│ └── desktop/ Electron デスクトップアプリ +│ ├── main.ts ウィンドウ、Nitro フォーク、ネイティブメニュー、自動アップデーター +│ ├── ipc-handlers.ts ネイティブファイルダイアログ、テーマ同期、設定 IPC +│ └── preload.ts IPC ブリッジ +├── packages/ +│ ├── pen-types/ PenDocument モデルの型定義 +│ ├── pen-core/ ドキュメントツリー操作、レイアウトエンジン、変数 +│ ├── pen-codegen/ コードジェネレーター(React、HTML、Vue、Flutter、...) +│ ├── pen-figma/ Figma .fig ファイルパーサーとコンバーター +│ ├── pen-renderer/ スタンドアロン CanvasKit/Skia レンダラー +│ └── pen-sdk/ アンブレラ SDK(全パッケージの再エクスポート) +└── .githooks/ ブランチ名からのプレコミットバージョン同期 ``` ## キーボードショートカット @@ -266,6 +278,7 @@ bun --bun run dev # 開発サーバー(ポート 3000) bun --bun run build # 本番ビルド bun --bun run test # テストの実行(Vitest) npx tsc --noEmit # 型チェック +bun run bump # すべての package.json のバージョンを同期 bun run electron:dev # Electron 開発モード bun run electron:build # Electron パッケージング ``` @@ -275,10 +288,11 @@ bun run electron:build # Electron パッケージング コントリビューションを歓迎します!アーキテクチャの詳細とコードスタイルについては [CLAUDE.md](./CLAUDE.md) をご覧ください。 1. フォークしてクローン -2. ブランチを作成:`git checkout -b feat/my-feature` -3. チェックを実行:`npx tsc --noEmit && bun --bun run test` -4. [Conventional Commits](https://www.conventionalcommits.org/) 形式でコミット:`feat(canvas): add rotation snapping` -5. `main` ブランチに PR を作成 +2. バージョン同期を設定:`git config core.hooksPath .githooks` +3. ブランチを作成:`git checkout -b feat/my-feature` +4. チェックを実行:`npx tsc --noEmit && bun --bun run test` +5. [Conventional Commits](https://www.conventionalcommits.org/) 形式でコミット:`feat(canvas): add rotation snapping` +6. `main` ブランチに PR を作成 ## ロードマップ @@ -290,6 +304,7 @@ bun run electron:build # Electron パッケージング - [x] Figma `.fig` インポート - [x] ブーリアン演算(合体、型抜き、交差) - [x] マルチモデル能力プロファイル +- [x] 再利用可能なパッケージによるモノレポ構成 - [ ] 共同編集 - [ ] プラグインシステム @@ -302,12 +317,11 @@ bun run electron:build # Electron パッケージング ## コミュニティ
- Discord + Discord Discord に参加する — 質問、デザインの共有、機能のリクエストはこちら。 - ## Star History diff --git a/README.ko.md b/README.ko.md index 6f948f21..bdadb097 100644 --- a/README.ko.md +++ b/README.ko.md @@ -1,5 +1,5 @@

- OpenPencil + OpenPencil

OpenPencil

@@ -102,7 +102,7 @@ bun run electron:dev > **필수 조건:** [Bun](https://bun.sh/) >= 1.0 및 [Node.js](https://nodejs.org/) >= 18 -### Docker 배포 +### Docker 여러 이미지 변형을 사용할 수 있습니다 — 필요에 맞는 것을 선택하세요: @@ -113,6 +113,7 @@ bun run electron:dev | `openpencil-codex:latest` | — | + Codex CLI | | `openpencil-opencode:latest` | — | + OpenCode CLI | | `openpencil-copilot:latest` | — | + GitHub Copilot CLI | +| `openpencil-gemini:latest` | — | + Gemini CLI | | `openpencil-full:latest` | ~1 GB | 모든 CLI 도구 | **실행 (웹만):** @@ -155,8 +156,8 @@ docker build --target full -t openpencil-full . **프롬프트에서 UI로** - **텍스트-투-디자인** — 페이지를 설명하면 스트리밍 애니메이션으로 실시간으로 캔버스에 생성 -- **오케스트레이터** — 복잡한 페이지를 공간적 서브태스크로 분해하여 병렬 생성 지원 -- **디자인 수정** — 요소를 선택하고 자연어로 변경 내용을 설명 +- **오케스트레이터** — 복잡한 페이지를 공간적 서브태스크로 분해하여 병렬 생성 +- **디자인 수정** — 요소를 선택하고 자연어로 변경 사항을 설명 - **비전 입력** — 스크린샷이나 목업을 참조로 첨부하여 디자인 **멀티 에이전트 지원** @@ -167,15 +168,16 @@ docker build --target full -t openpencil-full . | **Codex CLI** | 에이전트 설정에서 연결 (`Cmd+,`) | | **OpenCode** | 에이전트 설정에서 연결 (`Cmd+,`) | | **GitHub Copilot** | `copilot login` 후 에이전트 설정에서 연결 (`Cmd+,`) | +| **Gemini CLI** | 에이전트 설정에서 연결 (`Cmd+,`) | **모델 역량 프로파일** — 모델 티어에 따라 프롬프트, 사고 모드, 타임아웃을 자동 조정합니다. 풀 티어 모델(Claude)은 완전한 프롬프트를 받고, 스탠다드 티어(GPT-4o, Gemini, DeepSeek)는 사고 모드를 비활성화하며, 베이직 티어(MiniMax, Qwen, Llama, Mistral)는 최대 안정성을 위해 단순화된 중첩 JSON 프롬프트를 받습니다. **MCP 서버** - 내장 MCP 서버 — Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLI에 원클릭 설치 - Node.js 자동 감지 — 설치되지 않은 경우 HTTP 전송 모드로 자동 대체하고 MCP HTTP 서버를 자동 시작 -- 터미널에서 디자인 자동화: MCP 호환 에이전트를 통해 `.op` 파일 읽기, 생성, 편집 가능 +- 터미널에서 디자인 자동화: MCP 호환 에이전트를 통해 `.op` 파일 읽기, 생성, 편집 - **계층적 디자인 워크플로** — `design_skeleton` → `design_content` → `design_refine`으로 더 높은 충실도의 멀티 섹션 디자인 -- **세그먼트 프롬프트 검색** — 필요한 디자인 지식만 로드 (스키마, 레이아웃, 역할, 아이콘, 계획 등) +- **세그먼트 프롬프트 검색** — 필요한 디자인 지식만 로드 (schema, layout, roles, icons, planning 등) - 멀티 페이지 지원 — MCP 도구를 통해 페이지 생성, 이름 변경, 순서 변경, 복제 **코드 생성** @@ -186,15 +188,15 @@ docker build --target full -t openpencil-full . **캔버스 & 드로잉** - 팬, 줌, 스마트 정렬 가이드, 스냅 지원의 무한 캔버스 -- 사각형, 타원, 직선, 다각형, 펜(베지어), Frame, 텍스트 -- 불리언 연산 — 합치기, 빼기, 교차 (컨텍스트 도구 모음) +- Rectangle, Ellipse, Line, Polygon, Pen(Bezier), Frame, Text +- 불리언 연산 — 합치기, 빼기, 교차 (컨텍스트 툴바) - 아이콘 피커(Iconify)와 이미지 가져오기(PNG/JPEG/SVG/WebP/GIF) -- 오토 레이아웃 — 수직/수평 방향, 갭·패딩·justify·align 지원 +- 오토 레이아웃 — 수직/수평 방향, gap, padding, justify, align 지원 - 탭 내비게이션이 있는 멀티 페이지 문서 **디자인 시스템** -- 디자인 변수 — 컬러·숫자·문자열 토큰, `$variable` 참조 지원 -- 멀티 테마 지원 — 여러 테마 축, 각 축에 여러 변형(라이트/다크, 컴팩트/컴포터블) +- 디자인 변수 — 컬러, 숫자, 문자열 토큰, `$variable` 참조 지원 +- 멀티 테마 지원 — 여러 테마 축, 각 축에 변형(Light/Dark, Compact/Comfortable) - 컴포넌트 시스템 — 인스턴스와 오버라이드를 가진 재사용 가능한 컴포넌트 - CSS 동기화 — 커스텀 프로퍼티 자동 생성, 코드 출력에 `var(--name)` 사용 @@ -202,7 +204,7 @@ docker build --target full -t openpencil-full . - 레이아웃, 채우기, 선, 효과, 텍스트, 이미지, 벡터를 유지하며 `.fig` 파일 가져오기 **데스크톱 앱** -- Electron을 통한 네이티브 macOS·Windows·Linux 지원 +- Electron을 통한 네이티브 macOS, Windows, Linux 지원 - `.op` 파일 연결 — 더블 클릭으로 열기, 단일 인스턴스 잠금 - GitHub Releases에서 자동 업데이트 - 네이티브 애플리케이션 메뉴와 파일 다이얼로그 @@ -223,22 +225,32 @@ docker build --target full -t openpencil-full . ## 프로젝트 구조 ```text -src/ - canvas/ CanvasKit/Skia 엔진 — 드로잉, 동기화, 레이아웃, 가이드, 펜 툴 - components/ React UI — 에디터, 패널, 공유 다이얼로그, 아이콘 - services/ai/ AI 채팅, 오케스트레이터, 디자인 생성, 스트리밍 - services/figma/ Figma .fig 바이너리 가져오기 파이프라인 - services/codegen React+Tailwind 및 HTML+CSS 코드 생성기 - stores/ Zustand — 캔버스, 문서, 페이지, 히스토리, AI, 설정 - variables/ 디자인 토큰 해석 및 참조 관리 - mcp/ 외부 CLI 통합용 MCP 서버 툴 - uikit/ 재사용 가능한 컴포넌트 킷 시스템 -server/ - api/ai/ Nitro API — 스트리밍 채팅, 생성, 유효성 검사 - utils/ Claude CLI, OpenCode, Codex, Copilot 클라이언트 래퍼 -electron/ - main.ts 윈도우, Nitro 포크, 네이티브 메뉴, 자동 업데이터 - preload.ts IPC 브리지 +openpencil/ +├── apps/ +│ ├── web/ TanStack Start 웹 앱 +│ │ ├── src/ +│ │ │ ├── canvas/ CanvasKit/Skia 엔진 — 드로잉, 동기화, 레이아웃 +│ │ │ ├── components/ React UI — 에디터, 패널, 공유 다이얼로그, 아이콘 +│ │ │ ├── services/ai/ AI 채팅, 오케스트레이터, 디자인 생성, 스트리밍 +│ │ │ ├── stores/ Zustand — 캔버스, 문서, 페이지, 히스토리, AI +│ │ │ ├── mcp/ 외부 CLI 통합용 MCP 서버 도구 +│ │ │ ├── hooks/ 키보드 단축키, 파일 드롭, Figma 붙여넣기 +│ │ │ └── uikit/ 재사용 가능한 컴포넌트 킷 시스템 +│ │ └── server/ +│ │ ├── api/ai/ Nitro API — 스트리밍 채팅, 생성, 유효성 검사 +│ │ └── utils/ Claude CLI, OpenCode, Codex, Copilot 래퍼 +│ └── desktop/ Electron 데스크톱 앱 +│ ├── main.ts 윈도우, Nitro 포크, 네이티브 메뉴, 자동 업데이터 +│ ├── ipc-handlers.ts 네이티브 파일 대화상자, 테마 동기화, 환경설정 IPC +│ └── preload.ts IPC 브리지 +├── packages/ +│ ├── pen-types/ PenDocument 모델 타입 정의 +│ ├── pen-core/ 문서 트리 연산, 레이아웃 엔진, 변수 +│ ├── pen-codegen/ 코드 생성기 (React, HTML, Vue, Flutter, ...) +│ ├── pen-figma/ Figma .fig 파일 파서 및 변환기 +│ ├── pen-renderer/ 독립형 CanvasKit/Skia 렌더러 +│ └── pen-sdk/ 통합 SDK (모든 패키지 재export) +└── .githooks/ 브랜치 이름에서 버전 동기화를 위한 pre-commit ``` ## 키보드 단축키 @@ -266,6 +278,7 @@ bun --bun run dev # 개발 서버 (포트 3000) bun --bun run build # 프로덕션 빌드 bun --bun run test # 테스트 실행 (Vitest) npx tsc --noEmit # 타입 검사 +bun run bump # 모든 package.json에 버전 동기화 bun run electron:dev # Electron 개발 모드 bun run electron:build # Electron 패키징 ``` @@ -275,21 +288,23 @@ bun run electron:build # Electron 패키징 기여를 환영합니다! 아키텍처 세부 정보와 코드 스타일은 [CLAUDE.md](./CLAUDE.md)를 참고하세요. 1. 포크 후 클론 -2. 브랜치 생성: `git checkout -b feat/my-feature` -3. 검사 실행: `npx tsc --noEmit && bun --bun run test` -4. [Conventional Commits](https://www.conventionalcommits.org/) 형식으로 커밋: `feat(canvas): add rotation snapping` -5. `main` 브랜치에 PR 생성 +2. 버전 동기화 설정: `git config core.hooksPath .githooks` +3. 브랜치 생성: `git checkout -b feat/my-feature` +4. 검사 실행: `npx tsc --noEmit && bun --bun run test` +5. [Conventional Commits](https://www.conventionalcommits.org/) 형식으로 커밋: `feat(canvas): add rotation snapping` +6. `main` 브랜치에 PR 생성 ## 로드맵 - [x] CSS 동기화가 있는 디자인 변수 & 토큰 -- [x] 컴포넌트 시스템(인스턴스 & 오버라이드) +- [x] 컴포넌트 시스템 (인스턴스 & 오버라이드) - [x] 오케스트레이터를 통한 AI 디자인 생성 - [x] 계층적 디자인 워크플로가 포함된 MCP 서버 통합 - [x] 멀티 페이지 지원 - [x] Figma `.fig` 가져오기 -- [x] 불리언 연산(결합, 빼기, 교차) +- [x] 불리언 연산 (합치기, 빼기, 교차) - [x] 멀티 모델 역량 프로파일 +- [x] 재사용 가능한 패키지를 포함한 모노레포 구조 변경 - [ ] 공동 편집 - [ ] 플러그인 시스템 @@ -302,12 +317,11 @@ bun run electron:build # Electron 패키징 ## 커뮤니티
- Discord + Discord Discord에 참여하기 — 질문하기, 디자인 공유, 기능 제안. - ## Star History diff --git a/README.md b/README.md index ab443b89..316f092d 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- OpenPencil + OpenPencil

OpenPencil

@@ -113,6 +113,7 @@ Multiple image variants are available — pick the one that fits your needs: | `openpencil-codex:latest` | — | + Codex CLI | | `openpencil-opencode:latest` | — | + OpenCode CLI | | `openpencil-copilot:latest` | — | + GitHub Copilot CLI | +| `openpencil-gemini:latest` | — | + Gemini CLI | | `openpencil-full:latest` | ~1 GB | All CLI tools | **Run (web only):** @@ -167,6 +168,7 @@ docker build --target full -t openpencil-full . | **Codex CLI** | Connect in Agent Settings (`Cmd+,`) | | **OpenCode** | Connect in Agent Settings (`Cmd+,`) | | **GitHub Copilot** | `copilot login` then connect in Agent Settings (`Cmd+,`) | +| **Gemini CLI** | Connect in Agent Settings (`Cmd+,`) | **Model Capability Profiles** — automatically adapts prompts, thinking mode, and timeouts per model tier. Full-tier models (Claude) get complete prompts; standard-tier (GPT-4o, Gemini, DeepSeek) disable thinking; basic-tier (MiniMax, Qwen, Llama, Mistral) get simplified nested-JSON prompts for maximum reliability. @@ -223,22 +225,32 @@ docker build --target full -t openpencil-full . ## Project Structure ```text -src/ - canvas/ CanvasKit/Skia engine — drawing, sync, layout, guides, pen tool - components/ React UI — editor, panels, shared dialogs, icons - services/ai/ AI chat, orchestrator, design generation, streaming - services/figma/ Figma .fig binary import pipeline - services/codegen Multi-platform code generators (React, HTML, Vue, Svelte, Flutter, SwiftUI, Compose, React Native) - stores/ Zustand — canvas, document, pages, history, AI, settings - variables/ Design token resolution and reference management - mcp/ MCP server tools for external CLI integration - uikit/ Reusable component kit system -server/ - api/ai/ Nitro API — streaming chat, generation, validation - utils/ Claude CLI, OpenCode, Codex, Copilot client wrappers -electron/ - main.ts Window, Nitro fork, native menu, auto-updater - preload.ts IPC bridge +openpencil/ +├── apps/ +│ ├── web/ TanStack Start web app +│ │ ├── src/ +│ │ │ ├── canvas/ CanvasKit/Skia engine — drawing, sync, layout +│ │ │ ├── components/ React UI — editor, panels, shared dialogs, icons +│ │ │ ├── services/ai/ AI chat, orchestrator, design generation, streaming +│ │ │ ├── stores/ Zustand — canvas, document, pages, history, AI +│ │ │ ├── mcp/ MCP server tools for external CLI integration +│ │ │ ├── hooks/ Keyboard shortcuts, file drop, Figma paste +│ │ │ └── uikit/ Reusable component kit system +│ │ └── server/ +│ │ ├── api/ai/ Nitro API — streaming chat, generation, validation +│ │ └── utils/ Claude CLI, OpenCode, Codex, Copilot wrappers +│ └── desktop/ Electron desktop app +│ ├── main.ts Window, Nitro fork, native menu, auto-updater +│ ├── ipc-handlers.ts Native file dialogs, theme sync, prefs IPC +│ └── preload.ts IPC bridge +├── packages/ +│ ├── pen-types/ Type definitions for PenDocument model +│ ├── pen-core/ Document tree ops, layout engine, variables +│ ├── pen-codegen/ Code generators (React, HTML, Vue, Flutter, ...) +│ ├── pen-figma/ Figma .fig file parser and converter +│ ├── pen-renderer/ Standalone CanvasKit/Skia renderer +│ └── pen-sdk/ Umbrella SDK (re-exports all packages) +└── .githooks/ Pre-commit version sync from branch name ``` ## Keyboard Shortcuts @@ -266,6 +278,7 @@ bun --bun run dev # Dev server (port 3000) bun --bun run build # Production build bun --bun run test # Run tests (Vitest) npx tsc --noEmit # Type check +bun run bump # Sync version across all package.json bun run electron:dev # Electron dev bun run electron:build # Electron package ``` @@ -275,10 +288,11 @@ bun run electron:build # Electron package Contributions are welcome! See [CLAUDE.md](./CLAUDE.md) for architecture details and code style. 1. Fork and clone -2. Create a branch: `git checkout -b feat/my-feature` -3. Run checks: `npx tsc --noEmit && bun --bun run test` -4. Commit with [Conventional Commits](https://www.conventionalcommits.org/): `feat(canvas): add rotation snapping` -5. Open a PR against `main` +2. Set up version sync: `git config core.hooksPath .githooks` +3. Create a branch: `git checkout -b feat/my-feature` +4. Run checks: `npx tsc --noEmit && bun --bun run test` +5. Commit with [Conventional Commits](https://www.conventionalcommits.org/): `feat(canvas): add rotation snapping` +6. Open a PR against `main` ## Roadmap @@ -290,6 +304,7 @@ Contributions are welcome! See [CLAUDE.md](./CLAUDE.md) for architecture details - [x] Figma `.fig` import - [x] Boolean operations (union, subtract, intersect) - [x] Multi-model capability profiles +- [x] Monorepo restructure with reusable packages - [ ] Collaborative editing - [ ] Plugin system @@ -302,7 +317,7 @@ Contributions are welcome! See [CLAUDE.md](./CLAUDE.md) for architecture details ## Community
- Discord + Discord Join our Discord — Ask questions, share designs, suggest features. diff --git a/README.pt.md b/README.pt.md index c5c24761..841b40ce 100644 --- a/README.pt.md +++ b/README.pt.md @@ -1,5 +1,5 @@

- OpenPencil + OpenPencil

OpenPencil

@@ -10,7 +10,7 @@

- English · 简体中文 · 繁體中文 · 日本語 · 한국어 · Français · Español · Deutsch · Português · Русский · हिन्दी · Türkçe · ไทย · Tiếng Việt · Bahasa Indonesia + English · 简体中文 · 繁體中文 · 日本語 · 한국어 · Français · Español · Deutsch · Português · Русский · हिन्दी · Türkçe · ไทย · Tiếng Việt · Bahasa Indonesia

@@ -102,7 +102,7 @@ bun run electron:dev > **Pré-requisitos:** [Bun](https://bun.sh/) >= 1.0 e [Node.js](https://nodejs.org/) >= 18 -### Implantação com Docker +### Docker Várias variantes de imagem estão disponíveis — escolha a que se adequa às suas necessidades: @@ -113,6 +113,7 @@ Várias variantes de imagem estão disponíveis — escolha a que se adequa às | `openpencil-codex:latest` | — | + Codex CLI | | `openpencil-opencode:latest` | — | + OpenCode CLI | | `openpencil-copilot:latest` | — | + GitHub Copilot CLI | +| `openpencil-gemini:latest` | — | + Gemini CLI | | `openpencil-full:latest` | ~1 GB | Todas as ferramentas CLI | **Executar (apenas web):** @@ -167,6 +168,7 @@ docker build --target full -t openpencil-full . | **Codex CLI** | Conectar nas Configurações do Agente (`Cmd+,`) | | **OpenCode** | Conectar nas Configurações do Agente (`Cmd+,`) | | **GitHub Copilot** | `copilot login` e depois conectar nas Configurações do Agente (`Cmd+,`) | +| **Gemini CLI** | Conectar nas Configurações do Agente (`Cmd+,`) | **Perfis de Capacidade de Modelo** — adapta automaticamente prompts, modo de thinking e timeouts por nível de modelo. Modelos de nível completo (Claude) recebem prompts completos; nível padrão (GPT-4o, Gemini, DeepSeek) desativam thinking; nível básico (MiniMax, Qwen, Llama, Mistral) recebem prompts simplificados de JSON aninhado para máxima confiabilidade. @@ -223,22 +225,32 @@ docker build --target full -t openpencil-full . ## Estrutura do Projeto ```text -src/ - canvas/ Motor CanvasKit/Skia — desenho, sincronização, layout, guias, ferramenta caneta - components/ UI React — editor, painéis, diálogos compartilhados, ícones - services/ai/ Chat IA, orquestrador, geração de design, streaming - services/figma/ Pipeline de importação binária do Figma .fig - services/codegen Geradores de código React+Tailwind e HTML+CSS - stores/ Zustand — canvas, documento, páginas, histórico, IA, configurações - variables/ Resolução de tokens de design e gerenciamento de referências - mcp/ Ferramentas do servidor MCP para integração com CLI externo - uikit/ Sistema de kit de componentes reutilizáveis -server/ - api/ai/ API Nitro — chat em streaming, geração, validação - utils/ Wrappers de cliente Claude CLI, OpenCode, Codex, Copilot -electron/ - main.ts Janela, fork do Nitro, menu nativo, atualizador automático - preload.ts Ponte IPC +openpencil/ +├── apps/ +│ ├── web/ Aplicação web TanStack Start +│ │ ├── src/ +│ │ │ ├── canvas/ Motor CanvasKit/Skia — desenho, sincronização, layout +│ │ │ ├── components/ UI React — editor, painéis, diálogos compartilhados, ícones +│ │ │ ├── services/ai/ Chat IA, orquestrador, geração de design, streaming +│ │ │ ├── stores/ Zustand — canvas, documento, páginas, histórico, IA +│ │ │ ├── mcp/ Ferramentas do servidor MCP para integração com CLI externo +│ │ │ ├── hooks/ Atalhos de teclado, soltar arquivos, colar do Figma +│ │ │ └── uikit/ Sistema de kit de componentes reutilizáveis +│ │ └── server/ +│ │ ├── api/ai/ API Nitro — chat em streaming, geração, validação +│ │ └── utils/ Wrappers de cliente Claude CLI, OpenCode, Codex, Copilot +│ └── desktop/ Aplicativo desktop Electron +│ ├── main.ts Janela, fork do Nitro, menu nativo, atualizador automático +│ ├── ipc-handlers.ts Diálogos de arquivo nativos, sincronização de tema, preferências IPC +│ └── preload.ts Ponte IPC +├── packages/ +│ ├── pen-types/ Definições de tipos para o modelo PenDocument +│ ├── pen-core/ Operações de árvore de documento, motor de layout, variáveis +│ ├── pen-codegen/ Geradores de código (React, HTML, Vue, Flutter, ...) +│ ├── pen-figma/ Parser e conversor de arquivos .fig do Figma +│ ├── pen-renderer/ Renderizador CanvasKit/Skia independente +│ └── pen-sdk/ SDK guarda-chuva (re-exporta todos os pacotes) +└── .githooks/ Sincronização de versão no pre-commit a partir do nome da branch ``` ## Atalhos de Teclado @@ -266,6 +278,7 @@ bun --bun run dev # Servidor de desenvolvimento (porta 3000) bun --bun run build # Build de produção bun --bun run test # Executar testes (Vitest) npx tsc --noEmit # Verificação de tipos +bun run bump # Sincronizar versão em todos os package.json bun run electron:dev # Desenvolvimento com Electron bun run electron:build # Empacotamento do Electron ``` @@ -275,10 +288,11 @@ bun run electron:build # Empacotamento do Electron Contribuições são bem-vindas! Consulte o [CLAUDE.md](./CLAUDE.md) para detalhes de arquitetura e estilo de código. 1. Faça fork e clone -2. Crie uma branch: `git checkout -b feat/my-feature` -3. Execute as verificações: `npx tsc --noEmit && bun --bun run test` -4. Faça commit com [Conventional Commits](https://www.conventionalcommits.org/): `feat(canvas): add rotation snapping` -5. Abra um PR contra `main` +2. Configure a sincronização de versão: `git config core.hooksPath .githooks` +3. Crie uma branch: `git checkout -b feat/my-feature` +4. Execute as verificações: `npx tsc --noEmit && bun --bun run test` +5. Faça commit com [Conventional Commits](https://www.conventionalcommits.org/): `feat(canvas): add rotation snapping` +6. Abra um PR contra `main` ## Roadmap @@ -290,6 +304,7 @@ Contribuições são bem-vindas! Consulte o [CLAUDE.md](./CLAUDE.md) para detalh - [x] Importação do Figma `.fig` - [x] Operações booleanas (união, subtração, interseção) - [x] Perfis de capacidade multi-modelo +- [x] Reestruturação em monorepo com pacotes reutilizáveis - [ ] Edição colaborativa - [ ] Sistema de plugins @@ -302,12 +317,11 @@ Contribuições são bem-vindas! Consulte o [CLAUDE.md](./CLAUDE.md) para detalh ## Comunidade - Discord + Discord Entre no nosso Discord — Faça perguntas, compartilhe designs, sugira funcionalidades. - ## Star History diff --git a/README.ru.md b/README.ru.md index 452c459d..fdabce78 100644 --- a/README.ru.md +++ b/README.ru.md @@ -1,5 +1,5 @@

- OpenPencil + OpenPencil

OpenPencil

@@ -10,7 +10,7 @@

- English · 简体中文 · 繁體中文 · 日本語 · 한국어 · Français · Español · Deutsch · Português · Русский · हिन्दी · Türkçe · ไทย · Tiếng Việt · Bahasa Indonesia + English · 简体中文 · 繁體中文 · 日本語 · 한국어 · Français · Español · Deutsch · Português · Русский · हिन्दी · Türkçe · ไทย · Tiếng Việt · Bahasa Indonesia

@@ -102,7 +102,7 @@ bun run electron:dev > **Требования:** [Bun](https://bun.sh/) >= 1.0 и [Node.js](https://nodejs.org/) >= 18 -### Развёртывание через Docker +### Docker Доступно несколько вариантов образов — выберите подходящий для ваших нужд: @@ -113,6 +113,7 @@ bun run electron:dev | `openpencil-codex:latest` | — | + Codex CLI | | `openpencil-opencode:latest` | — | + OpenCode CLI | | `openpencil-copilot:latest` | — | + GitHub Copilot CLI | +| `openpencil-gemini:latest` | — | + Gemini CLI | | `openpencil-full:latest` | ~1 ГБ | Все CLI-инструменты | **Запуск (только веб):** @@ -167,6 +168,7 @@ docker build --target full -t openpencil-full . | **Codex CLI** | Подключить в настройках агента (`Cmd+,`) | | **OpenCode** | Подключить в настройках агента (`Cmd+,`) | | **GitHub Copilot** | `copilot login`, затем подключить в настройках агента (`Cmd+,`) | +| **Gemini CLI** | Подключить в настройках агента (`Cmd+,`) | **Профили возможностей моделей** — автоматически адаптирует промпты, режим thinking и таймауты для каждого уровня моделей. Модели полного уровня (Claude) получают полные промпты; стандартного уровня (GPT-4o, Gemini, DeepSeek) — с отключённым thinking; базового уровня (MiniMax, Qwen, Llama, Mistral) — упрощённые промпты с вложенным JSON для максимальной надёжности. @@ -223,22 +225,32 @@ docker build --target full -t openpencil-full . ## Структура проекта ```text -src/ - canvas/ Движок CanvasKit/Skia — рисование, синхронизация, раскладка, направляющие, инструмент пера - components/ React UI — редактор, панели, общие диалоги, иконки - services/ai/ AI-чат, оркестратор, генерация дизайна, стриминг - services/figma/ Пайплайн бинарного импорта Figma .fig - services/codegen Генераторы кода React+Tailwind и HTML+CSS - stores/ Zustand — холст, документ, страницы, история, AI, настройки - variables/ Разрешение дизайн-токенов и управление ссылками - mcp/ Инструменты MCP-сервера для интеграции с внешними CLI - uikit/ Система переиспользуемых наборов компонентов -server/ - api/ai/ Nitro API — стриминговый чат, генерация, валидация - utils/ Обёртки клиентов Claude CLI, OpenCode, Codex, Copilot -electron/ - main.ts Окно, форк Nitro, нативное меню, автообновление - preload.ts IPC-мост +openpencil/ +├── apps/ +│ ├── web/ Веб-приложение TanStack Start +│ │ ├── src/ +│ │ │ ├── canvas/ Движок CanvasKit/Skia — рисование, синхронизация, раскладка +│ │ │ ├── components/ React UI — редактор, панели, общие диалоги, иконки +│ │ │ ├── services/ai/ AI-чат, оркестратор, генерация дизайна, стриминг +│ │ │ ├── stores/ Zustand — холст, документ, страницы, история, AI +│ │ │ ├── mcp/ Инструменты MCP-сервера для интеграции с внешними CLI +│ │ │ ├── hooks/ Горячие клавиши, перетаскивание файлов, вставка из Figma +│ │ │ └── uikit/ Система переиспользуемых наборов компонентов +│ │ └── server/ +│ │ ├── api/ai/ Nitro API — стриминговый чат, генерация, валидация +│ │ └── utils/ Обёртки клиентов Claude CLI, OpenCode, Codex, Copilot +│ └── desktop/ Десктопное приложение Electron +│ ├── main.ts Окно, форк Nitro, нативное меню, автообновление +│ ├── ipc-handlers.ts Нативные файловые диалоги, синхронизация темы, настройки IPC +│ └── preload.ts IPC-мост +├── packages/ +│ ├── pen-types/ Определения типов для модели PenDocument +│ ├── pen-core/ Операции с деревом документа, движок раскладки, переменные +│ ├── pen-codegen/ Генераторы кода (React, HTML, Vue, Flutter, ...) +│ ├── pen-figma/ Парсер и конвертер файлов Figma .fig +│ ├── pen-renderer/ Автономный рендерер CanvasKit/Skia +│ └── pen-sdk/ Зонтичный SDK (реэкспортирует все пакеты) +└── .githooks/ Pre-commit синхронизация версий из имени ветки ``` ## Горячие клавиши @@ -266,6 +278,7 @@ bun --bun run dev # Сервер разработки (порт 3000) bun --bun run build # Сборка для продакшена bun --bun run test # Запустить тесты (Vitest) npx tsc --noEmit # Проверка типов +bun run bump # Синхронизация версий во всех package.json bun run electron:dev # Разработка Electron bun run electron:build # Упаковка Electron ``` @@ -275,10 +288,11 @@ bun run electron:build # Упаковка Electron Мы приветствуем вклад в проект! Подробности об архитектуре и стиле кода смотрите в [CLAUDE.md](./CLAUDE.md). 1. Сделайте форк и клонируйте репозиторий -2. Создайте ветку: `git checkout -b feat/my-feature` -3. Запустите проверки: `npx tsc --noEmit && bun --bun run test` -4. Сделайте коммит в формате [Conventional Commits](https://www.conventionalcommits.org/): `feat(canvas): add rotation snapping` -5. Откройте PR в ветку `main` +2. Настройте синхронизацию версий: `git config core.hooksPath .githooks` +3. Создайте ветку: `git checkout -b feat/my-feature` +4. Запустите проверки: `npx tsc --noEmit && bun --bun run test` +5. Сделайте коммит в формате [Conventional Commits](https://www.conventionalcommits.org/): `feat(canvas): add rotation snapping` +6. Откройте PR в ветку `main` ## Дорожная карта @@ -290,6 +304,7 @@ bun run electron:build # Упаковка Electron - [x] Импорт Figma `.fig` - [x] Булевы операции (объединение, вычитание, пересечение) - [x] Мультимодельные профили возможностей +- [x] Реструктуризация в монорепозиторий с переиспользуемыми пакетами - [ ] Совместное редактирование - [ ] Система плагинов @@ -302,12 +317,11 @@ bun run electron:build # Упаковка Electron ## Сообщество - Discord + Discord Присоединяйтесь к нашему Discord — Задавайте вопросы, делитесь дизайнами, предлагайте функции. - ## Star History diff --git a/README.th.md b/README.th.md index 4610cc32..a3947287 100644 --- a/README.th.md +++ b/README.th.md @@ -1,5 +1,5 @@

- OpenPencil + OpenPencil

OpenPencil

@@ -10,7 +10,7 @@

- English · 简体中文 · 繁體中文 · 日本語 · 한국어 · Français · Español · Deutsch · Português · Русский · हिन्दी · Türkçe · ไทย · Tiếng Việt · Bahasa Indonesia + English · 简体中文 · 繁體中文 · 日本語 · 한국어 · Français · Español · Deutsch · Português · Русский · हिन्दी · Türkçe · ไทย · Tiếng Việt · Bahasa Indonesia

@@ -102,7 +102,7 @@ bun run electron:dev > **ข้อกำหนดเบื้องต้น:** [Bun](https://bun.sh/) >= 1.0 และ [Node.js](https://nodejs.org/) >= 18 -### การติดตั้งด้วย Docker +### Docker มี image หลายรูปแบบให้เลือก — เลือกแบบที่เหมาะกับความต้องการของคุณ: @@ -113,6 +113,7 @@ bun run electron:dev | `openpencil-codex:latest` | — | + Codex CLI | | `openpencil-opencode:latest` | — | + OpenCode CLI | | `openpencil-copilot:latest` | — | + GitHub Copilot CLI | +| `openpencil-gemini:latest` | — | + Gemini CLI | | `openpencil-full:latest` | ~1 GB | เครื่องมือ CLI ทั้งหมด | **รัน (เว็บเท่านั้น):** @@ -167,13 +168,14 @@ docker build --target full -t openpencil-full . | **Codex CLI** | เชื่อมต่อใน Agent Settings (`Cmd+,`) | | **OpenCode** | เชื่อมต่อใน Agent Settings (`Cmd+,`) | | **GitHub Copilot** | `copilot login` จากนั้นเชื่อมต่อใน Agent Settings (`Cmd+,`) | +| **Gemini CLI** | เชื่อมต่อใน Agent Settings (`Cmd+,`) | **โปรไฟล์ความสามารถของโมเดล** — ปรับ prompt, โหมด thinking และ timeout ตามระดับโมเดลโดยอัตโนมัติ โมเดลระดับเต็ม (Claude) ได้ prompt ครบถ้วน; โมเดลระดับมาตรฐาน (GPT-4o, Gemini, DeepSeek) ปิด thinking; โมเดลระดับพื้นฐาน (MiniMax, Qwen, Llama, Mistral) ได้ prompt แบบ nested-JSON ที่ย่อลงเพื่อความเสถียรสูงสุด **MCP Server** - MCP Server ในตัว — ติดตั้งได้ด้วยคลิกเดียวใน Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLIs -- ตรวจจับ Node.js อัตโนมัติ — หากไม่ได้ติดตั้ง จะสำรองไปใช้ HTTP transport โดยอัตโนมัติและเริ่ม MCP HTTP เซิร์ฟเวอร์ -- การทำ Design Automation จาก Terminal: อ่าน สร้าง และแก้ไขไฟล์ `.op` ผ่าน agent ที่รองรับ MCP +- ตรวจจับ Node.js อัตโนมัติ — หากไม่ได้ติดตั้ง จะสำรองไปใช้ HTTP transport และเริ่ม MCP HTTP server โดยอัตโนมัติ +- การทำ Design automation จาก terminal: อ่าน สร้าง และแก้ไขไฟล์ `.op` ผ่าน agent ที่รองรับ MCP - **Layered design workflow** — `design_skeleton` → `design_content` → `design_refine` สำหรับดีไซน์หลายส่วนที่มีความละเอียดสูงขึ้น - **Segmented prompt retrieval** — โหลดเฉพาะความรู้ด้านดีไซน์ที่ต้องการ (schema, layout, roles, icons, planning ฯลฯ) - รองรับหลายหน้า — สร้าง เปลี่ยนชื่อ เรียงลำดับ และทำซ้ำหน้าผ่าน MCP tools @@ -223,22 +225,32 @@ docker build --target full -t openpencil-full . ## โครงสร้างโปรเจกต์ ```text -src/ - canvas/ CanvasKit/Skia engine — การวาด, sync, layout, guides, pen tool - components/ React UI — editor, panels, shared dialogs, icons - services/ai/ AI chat, orchestrator, การสร้างดีไซน์, streaming - services/figma/ Figma .fig binary import pipeline - services/codegen React+Tailwind และ HTML+CSS code generators - stores/ Zustand — canvas, document, pages, history, AI, settings - variables/ การแก้ไข design token และการจัดการ reference - mcp/ MCP server tools สำหรับการเชื่อมต่อ CLI ภายนอก - uikit/ ระบบ component kit ที่นำกลับมาใช้ใหม่ได้ -server/ - api/ai/ Nitro API — streaming chat, generation, validation - utils/ Claude CLI, OpenCode, Codex, Copilot client wrappers -electron/ - main.ts Window, Nitro fork, native menu, auto-updater - preload.ts IPC bridge +openpencil/ +├── apps/ +│ ├── web/ TanStack Start web app +│ │ ├── src/ +│ │ │ ├── canvas/ CanvasKit/Skia engine — การวาด, sync, layout +│ │ │ ├── components/ React UI — editor, panels, shared dialogs, icons +│ │ │ ├── services/ai/ AI chat, orchestrator, การสร้างดีไซน์, streaming +│ │ │ ├── stores/ Zustand — canvas, document, pages, history, AI +│ │ │ ├── mcp/ MCP server tools สำหรับการเชื่อมต่อ CLI ภายนอก +│ │ │ ├── hooks/ Keyboard shortcuts, file drop, Figma paste +│ │ │ └── uikit/ ระบบ component kit ที่นำกลับมาใช้ใหม่ได้ +│ │ └── server/ +│ │ ├── api/ai/ Nitro API — streaming chat, generation, validation +│ │ └── utils/ Claude CLI, OpenCode, Codex, Copilot wrappers +│ └── desktop/ Electron desktop app +│ ├── main.ts Window, Nitro fork, native menu, auto-updater +│ ├── ipc-handlers.ts ไดอะล็อกไฟล์เนทีฟ, ซิงค์ธีม, การตั้งค่า IPC +│ └── preload.ts IPC bridge +├── packages/ +│ ├── pen-types/ Type definitions สำหรับ PenDocument model +│ ├── pen-core/ Document tree ops, layout engine, variables +│ ├── pen-codegen/ Code generators (React, HTML, Vue, Flutter, ...) +│ ├── pen-figma/ Figma .fig file parser และ converter +│ ├── pen-renderer/ Standalone CanvasKit/Skia renderer +│ └── pen-sdk/ Umbrella SDK (re-exports ทุก package) +└── .githooks/ Pre-commit version sync จาก branch name ``` ## คีย์ลัด @@ -266,6 +278,7 @@ bun --bun run dev # Dev server (port 3000) bun --bun run build # Production build bun --bun run test # รันการทดสอบ (Vitest) npx tsc --noEmit # ตรวจสอบ type +bun run bump # Sync version ในทุก package.json bun run electron:dev # Electron dev bun run electron:build # Electron package ``` @@ -275,10 +288,11 @@ bun run electron:build # Electron package ยินดีต้อนรับการมีส่วนร่วมทุกรูปแบบ! ดู [CLAUDE.md](./CLAUDE.md) สำหรับรายละเอียดสถาปัตยกรรมและรูปแบบโค้ด 1. Fork และ clone -2. สร้าง branch: `git checkout -b feat/my-feature` -3. รันการตรวจสอบ: `npx tsc --noEmit && bun --bun run test` -4. Commit ด้วย [Conventional Commits](https://www.conventionalcommits.org/): `feat(canvas): add rotation snapping` -5. เปิด PR เข้า `main` +2. ตั้งค่า version sync: `git config core.hooksPath .githooks` +3. สร้าง branch: `git checkout -b feat/my-feature` +4. รันการตรวจสอบ: `npx tsc --noEmit && bun --bun run test` +5. Commit ด้วย [Conventional Commits](https://www.conventionalcommits.org/): `feat(canvas): add rotation snapping` +6. เปิด PR เข้า `main` ## Roadmap @@ -290,6 +304,7 @@ bun run electron:build # Electron package - [x] นำเข้า Figma `.fig` - [x] Boolean operations (union, subtract, intersect) - [x] โปรไฟล์ความสามารถหลายโมเดล +- [x] ปรับโครงสร้างเป็น monorepo พร้อม package ที่นำกลับมาใช้ใหม่ได้ - [ ] การแก้ไขร่วมกัน - [ ] ระบบปลั๊กอิน @@ -302,12 +317,11 @@ bun run electron:build # Electron package ## ชุมชน - Discord + Discord เข้าร่วม Discord ของเรา — ถามคำถาม แชร์ดีไซน์ เสนอฟีเจอร์ - ## Star History diff --git a/README.tr.md b/README.tr.md index 2f1d08dc..967e149d 100644 --- a/README.tr.md +++ b/README.tr.md @@ -1,16 +1,16 @@

- OpenPencil + OpenPencil

OpenPencil

- Dunyanin ilk acik kaynakli AI-yerel vektor tasarim araci.
- Eszamanli Ajan Ekipleri • Kod Olarak Tasarim • Yerlesik MCP Sunucusu • Coklu Model Zekasi + Dünyanın ilk açık kaynaklı AI-yerel vektör tasarım aracı.
+ Eşzamanlı Ajan Ekipleri • Kod Olarak Tasarım • Yerleşik MCP Sunucusu • Çoklu Model Zekası

- English · 简体中文 · 繁體中文 · 日本語 · 한국어 · Français · Español · Deutsch · Português · Русский · हिन्दी · Türkçe · ไทย · Tiếng Việt · Bahasa Indonesia + English · 简体中文 · 繁體中文 · 日本語 · 한국어 · Français · Español · Deutsch · Português · Русский · हिन्दी · Türkçe · ไทย · Tiếng Việt · Bahasa Indonesia

@@ -102,7 +102,7 @@ bun run electron:dev > **Ön koşullar:** [Bun](https://bun.sh/) >= 1.0 ve [Node.js](https://nodejs.org/) >= 18 -### Docker ile Dağıtım +### Docker Birden fazla görüntü varyantı mevcuttur — ihtiyaçlarınıza uygun olanı seçin: @@ -113,6 +113,7 @@ Birden fazla görüntü varyantı mevcuttur — ihtiyaçlarınıza uygun olanı | `openpencil-codex:latest` | — | + Codex CLI | | `openpencil-opencode:latest` | — | + OpenCode CLI | | `openpencil-copilot:latest` | — | + GitHub Copilot CLI | +| `openpencil-gemini:latest` | — | + Gemini CLI | | `openpencil-full:latest` | ~1 GB | Tüm CLI araçları | **Çalıştır (yalnızca web):** @@ -167,6 +168,7 @@ docker build --target full -t openpencil-full . | **Codex CLI** | Ajan Ayarlarından bağlanın (`Cmd+,`) | | **OpenCode** | Ajan Ayarlarından bağlanın (`Cmd+,`) | | **GitHub Copilot** | `copilot login` ardından Ajan Ayarlarından bağlanın (`Cmd+,`) | +| **Gemini CLI** | Ajan Ayarlarından bağlanın (`Cmd+,`) | **Model Yetenek Profilleri** — promptları, düşünme modunu ve zaman aşımlarını model katmanına göre otomatik olarak uyarlar. Tam katman modeller (Claude) eksiksiz promptlar alır; standart katman (GPT-4o, Gemini, DeepSeek) düşünme modunu devre dışı bırakır; temel katman (MiniMax, Qwen, Llama, Mistral) maksimum güvenilirlik için basitleştirilmiş iç içe JSON promptları alır. @@ -212,7 +214,7 @@ docker build --target full -t openpencil-full . | | | | --- | --- | | **Ön Uç** | React 19 · TanStack Start · Tailwind CSS v4 · shadcn/ui | -| **Kanvas** | CanvasKit/Skia (WASM, GPU hizlandirmali) | +| **Kanvas** | CanvasKit/Skia (WASM, GPU hızlandırmalı) | | **Durum Yönetimi** | Zustand v5 | | **Sunucu** | Nitro | | **Masaüstü** | Electron 35 | @@ -223,22 +225,32 @@ docker build --target full -t openpencil-full . ## Proje Yapısı ```text -src/ - canvas/ CanvasKit/Skia motoru — çizim, senkronizasyon, düzen, kılavuzlar, kalem aracı - components/ React UI — editör, paneller, paylaşılan iletişim kutuları, simgeler - services/ai/ AI sohbet, orkestratör, tasarım üretimi, akış - services/figma/ Figma .fig ikili içe aktarma ardışık düzeni - services/codegen React+Tailwind ve HTML+CSS kod üreticileri - stores/ Zustand — kanvas, belge, sayfalar, geçmiş, AI, ayarlar - variables/ Tasarım token çözümleme ve referans yönetimi - mcp/ Harici CLI entegrasyonu için MCP sunucu araçları - uikit/ Yeniden kullanılabilir bileşen kiti sistemi -server/ - api/ai/ Nitro API — akış sohbet, üretim, doğrulama - utils/ Claude CLI, OpenCode, Codex, Copilot istemci sarmalayıcıları -electron/ - main.ts Pencere, Nitro çatallanması, yerel menü, otomatik güncelleyici - preload.ts IPC köprüsü +openpencil/ +├── apps/ +│ ├── web/ TanStack Start web uygulaması +│ │ ├── src/ +│ │ │ ├── canvas/ CanvasKit/Skia motoru — çizim, senkronizasyon, düzen +│ │ │ ├── components/ React UI — editör, paneller, paylaşılan iletişim kutuları, simgeler +│ │ │ ├── services/ai/ AI sohbet, orkestratör, tasarım üretimi, akış +│ │ │ ├── stores/ Zustand — kanvas, belge, sayfalar, geçmiş, AI +│ │ │ ├── mcp/ Harici CLI entegrasyonu için MCP sunucu araçları +│ │ │ ├── hooks/ Klavye kısayolları, dosya bırakma, Figma yapıştırma +│ │ │ └── uikit/ Yeniden kullanılabilir bileşen kiti sistemi +│ │ └── server/ +│ │ ├── api/ai/ Nitro API — akış sohbet, üretim, doğrulama +│ │ └── utils/ Claude CLI, OpenCode, Codex, Copilot sarmalayıcıları +│ └── desktop/ Electron masaüstü uygulaması +│ ├── main.ts Pencere, Nitro çatallanması, yerel menü, otomatik güncelleyici +│ ├── ipc-handlers.ts Yerel dosya diyalogları, tema senkronizasyonu, tercihler IPC +│ └── preload.ts IPC köprüsü +├── packages/ +│ ├── pen-types/ PenDocument modeli için tür tanımları +│ ├── pen-core/ Belge ağacı işlemleri, düzen motoru, değişkenler +│ ├── pen-codegen/ Kod oluşturucular (React, HTML, Vue, Flutter, ...) +│ ├── pen-figma/ Figma .fig dosya ayrıştırıcı ve dönüştürücü +│ ├── pen-renderer/ Bağımsız CanvasKit/Skia işleyici +│ └── pen-sdk/ Şemsiye SDK (tüm paketleri yeniden dışa aktarır) +└── .githooks/ Dal adından ön-commit sürüm eşitleme ``` ## Klavye Kısayolları @@ -266,6 +278,7 @@ bun --bun run dev # Geliştirme sunucusu (port 3000) bun --bun run build # Üretim derlemesi bun --bun run test # Testleri çalıştır (Vitest) npx tsc --noEmit # Tür denetimi +bun run bump # Tüm package.json dosyalarında sürümü eşitle bun run electron:dev # Electron geliştirme modu bun run electron:build # Electron paketleme ``` @@ -275,10 +288,11 @@ bun run electron:build # Electron paketleme Katkılarınızı bekliyoruz! Mimari ayrıntılar ve kod stili için [CLAUDE.md](./CLAUDE.md) dosyasına bakın. 1. Fork'layın ve klonlayın -2. Dal oluşturun: `git checkout -b feat/my-feature` -3. Kontrolleri çalıştırın: `npx tsc --noEmit && bun --bun run test` -4. [Conventional Commits](https://www.conventionalcommits.org/) formatıyla commit yapın: `feat(canvas): add rotation snapping` -5. `main` dalına PR açın +2. Sürüm eşitlemeyi ayarlayın: `git config core.hooksPath .githooks` +3. Dal oluşturun: `git checkout -b feat/my-feature` +4. Kontrolleri çalıştırın: `npx tsc --noEmit && bun --bun run test` +5. [Conventional Commits](https://www.conventionalcommits.org/) formatıyla commit yapın: `feat(canvas): add rotation snapping` +6. `main` dalına PR açın ## Yol Haritası @@ -290,6 +304,7 @@ Katkılarınızı bekliyoruz! Mimari ayrıntılar ve kod stili için [CLAUDE.md] - [x] Figma `.fig` içe aktarma - [x] Boolean işlemler (birleştirme, çıkarma, kesişim) - [x] Çoklu model yetenek profilleri +- [x] Yeniden kullanılabilir paketlerle monorepo yapılandırması - [ ] Ortak düzenleme - [ ] Eklenti sistemi @@ -302,12 +317,11 @@ Katkılarınızı bekliyoruz! Mimari ayrıntılar ve kod stili için [CLAUDE.md] ## Topluluk - Discord + Discord Discord'umuza katılın — Soru sorun, tasarımlarınızı paylaşın, özellik önerin. - ## Star History diff --git a/README.vi.md b/README.vi.md index 4464faad..3e9b419c 100644 --- a/README.vi.md +++ b/README.vi.md @@ -1,5 +1,5 @@

- OpenPencil + OpenPencil

OpenPencil

@@ -10,7 +10,7 @@

- English · 简体中文 · 繁體中文 · 日本語 · 한국어 · Français · Español · Deutsch · Português · Русский · हिन्दी · Türkçe · ไทย · Tiếng Việt · Bahasa Indonesia + English · 简体中文 · 繁體中文 · 日本語 · 한국어 · Français · Español · Deutsch · Português · Русский · हिन्दी · Türkçe · ไทย · Tiếng Việt · Bahasa Indonesia

@@ -102,7 +102,7 @@ bun run electron:dev > **Yêu cầu:** [Bun](https://bun.sh/) >= 1.0 và [Node.js](https://nodejs.org/) >= 18 -### Triển khai bằng Docker +### Docker Có nhiều biến thể image khác nhau — chọn loại phù hợp với nhu cầu của bạn: @@ -113,6 +113,7 @@ Có nhiều biến thể image khác nhau — chọn loại phù hợp với nhu | `openpencil-codex:latest` | — | + Codex CLI | | `openpencil-opencode:latest` | — | + OpenCode CLI | | `openpencil-copilot:latest` | — | + GitHub Copilot CLI | +| `openpencil-gemini:latest` | — | + Gemini CLI | | `openpencil-full:latest` | ~1 GB | Tất cả công cụ CLI | **Chạy (chỉ web):** @@ -167,6 +168,7 @@ docker build --target full -t openpencil-full . | **Codex CLI** | Kết nối trong Cài đặt tác nhân (`Cmd+,`) | | **OpenCode** | Kết nối trong Cài đặt tác nhân (`Cmd+,`) | | **GitHub Copilot** | `copilot login` rồi kết nối trong Cài đặt tác nhân (`Cmd+,`) | +| **Gemini CLI** | Kết nối trong Cài đặt tác nhân (`Cmd+,`) | **Hồ sơ Năng lực Mô hình** — tự động thích ứng prompt, chế độ thinking và thời gian chờ theo từng cấp mô hình. Mô hình cấp đầy đủ (Claude) nhận prompt hoàn chỉnh; cấp tiêu chuẩn (GPT-4o, Gemini, DeepSeek) tắt thinking; cấp cơ bản (MiniMax, Qwen, Llama, Mistral) nhận prompt JSON lồng nhau đơn giản hóa để đảm bảo độ tin cậy tối đa. @@ -223,22 +225,32 @@ docker build --target full -t openpencil-full . ## Cấu trúc dự án ```text -src/ - canvas/ CanvasKit/Skia engine — vẽ, đồng bộ, layout, hướng dẫn, công cụ bút - components/ React UI — editor, panels, hộp thoại dùng chung, icons - services/ai/ AI chat, orchestrator, tạo thiết kế, streaming - services/figma/ Pipeline nhập binary Figma .fig - services/codegen Bộ tạo mã React+Tailwind và HTML+CSS - stores/ Zustand — canvas, document, pages, history, AI, settings - variables/ Giải quyết token thiết kế và quản lý tham chiếu - mcp/ Công cụ máy chủ MCP để tích hợp CLI bên ngoài - uikit/ Hệ thống kit component có thể tái sử dụng -server/ - api/ai/ Nitro API — streaming chat, generation, validation - utils/ Claude CLI, OpenCode, Codex, Copilot client wrappers -electron/ - main.ts Cửa sổ, Nitro fork, menu gốc, auto-updater - preload.ts IPC bridge +openpencil/ +├── apps/ +│ ├── web/ Ứng dụng web TanStack Start +│ │ ├── src/ +│ │ │ ├── canvas/ Engine CanvasKit/Skia — vẽ, đồng bộ, layout +│ │ │ ├── components/ React UI — editor, panels, hộp thoại dùng chung, icons +│ │ │ ├── services/ai/ AI chat, orchestrator, tạo thiết kế, streaming +│ │ │ ├── stores/ Zustand — canvas, document, pages, history, AI +│ │ │ ├── mcp/ Công cụ máy chủ MCP để tích hợp CLI bên ngoài +│ │ │ ├── hooks/ Phím tắt, kéo thả tệp, dán từ Figma +│ │ │ └── uikit/ Hệ thống kit component có thể tái sử dụng +│ │ └── server/ +│ │ ├── api/ai/ Nitro API — streaming chat, generation, validation +│ │ └── utils/ Claude CLI, OpenCode, Codex, Copilot wrappers +│ └── desktop/ Ứng dụng desktop Electron +│ ├── main.ts Cửa sổ, Nitro fork, menu gốc, auto-updater +│ ├── ipc-handlers.ts Hộp thoại file gốc, đồng bộ theme, tùy chọn IPC +│ └── preload.ts IPC bridge +├── packages/ +│ ├── pen-types/ Định nghĩa kiểu cho mô hình PenDocument +│ ├── pen-core/ Thao tác cây tài liệu, layout engine, biến +│ ├── pen-codegen/ Bộ tạo mã (React, HTML, Vue, Flutter, ...) +│ ├── pen-figma/ Trình phân tích và chuyển đổi tệp Figma .fig +│ ├── pen-renderer/ Bộ dựng hình CanvasKit/Skia độc lập +│ └── pen-sdk/ SDK tổng hợp (tái xuất tất cả các gói) +└── .githooks/ Pre-commit đồng bộ phiên bản từ tên nhánh ``` ## Phím tắt @@ -266,6 +278,7 @@ bun --bun run dev # Máy chủ phát triển (cổng 3000) bun --bun run build # Build production bun --bun run test # Chạy kiểm thử (Vitest) npx tsc --noEmit # Kiểm tra kiểu +bun run bump # Đồng bộ phiên bản trên tất cả package.json bun run electron:dev # Electron dev bun run electron:build # Đóng gói Electron ``` @@ -275,10 +288,11 @@ bun run electron:build # Đóng gói Electron Chào mừng đóng góp! Xem [CLAUDE.md](./CLAUDE.md) để biết chi tiết về kiến trúc và phong cách mã. 1. Fork và clone -2. Tạo branch: `git checkout -b feat/my-feature` -3. Chạy kiểm tra: `npx tsc --noEmit && bun --bun run test` -4. Commit theo [Conventional Commits](https://www.conventionalcommits.org/): `feat(canvas): add rotation snapping` -5. Mở PR vào nhánh `main` +2. Thiết lập đồng bộ phiên bản: `git config core.hooksPath .githooks` +3. Tạo branch: `git checkout -b feat/my-feature` +4. Chạy kiểm tra: `npx tsc --noEmit && bun --bun run test` +5. Commit theo [Conventional Commits](https://www.conventionalcommits.org/): `feat(canvas): add rotation snapping` +6. Mở PR vào nhánh `main` ## Lộ trình @@ -290,6 +304,7 @@ Chào mừng đóng góp! Xem [CLAUDE.md](./CLAUDE.md) để biết chi tiết v - [x] Nhập Figma `.fig` - [x] Phép toán Boolean (hợp nhất, trừ, giao) - [x] Hồ sơ năng lực đa mô hình +- [x] Tái cấu trúc monorepo với các gói tái sử dụng - [ ] Chỉnh sửa cộng tác - [ ] Hệ thống plugin @@ -302,12 +317,11 @@ Chào mừng đóng góp! Xem [CLAUDE.md](./CLAUDE.md) để biết chi tiết v ## Cộng đồng - Discord + Discord Tham gia Discord của chúng tôi — Đặt câu hỏi, chia sẻ thiết kế, đề xuất tính năng. - ## Star History diff --git a/README.zh-TW.md b/README.zh-TW.md index 8040f443..bf9ba7f5 100644 --- a/README.zh-TW.md +++ b/README.zh-TW.md @@ -1,5 +1,5 @@

- OpenPencil + OpenPencil

OpenPencil

@@ -10,7 +10,7 @@

- English · 简体中文 · 繁體中文 · 日本語 · 한국어 · Français · Español · Deutsch · Português · Русский · हिन्दी · Türkçe · ไทย · Tiếng Việt · Bahasa Indonesia + English · 简体中文 · 繁體中文 · 日本語 · 한국어 · Français · Español · Deutsch · Português · Русский · हिन्दी · Türkçe · ไทย · Tiếng Việt · Bahasa Indonesia

@@ -102,7 +102,7 @@ bun run electron:dev > **前置條件:** [Bun](https://bun.sh/) >= 1.0 以及 [Node.js](https://nodejs.org/) >= 18 -### Docker 部署 +### Docker 提供多種映像檔變體 — 選擇適合您需求的版本: @@ -113,6 +113,7 @@ bun run electron:dev | `openpencil-codex:latest` | — | + Codex CLI | | `openpencil-opencode:latest` | — | + OpenCode CLI | | `openpencil-copilot:latest` | — | + GitHub Copilot CLI | +| `openpencil-gemini:latest` | — | + Gemini CLI | | `openpencil-full:latest` | ~1 GB | 所有 CLI 工具 | **執行(僅 Web):** @@ -167,6 +168,7 @@ docker build --target full -t openpencil-full . | **Codex CLI** | 在 Agent 設定中連接(`Cmd+,`) | | **OpenCode** | 在 Agent 設定中連接(`Cmd+,`) | | **GitHub Copilot** | 執行 `copilot login` 後在 Agent 設定中連接(`Cmd+,`) | +| **Gemini CLI** | 在 Agent 設定中連接(`Cmd+,`) | **模型能力設定檔** — 自動依據模型層級調整提示詞、思考模式和逾時設定。完整層級模型(Claude)獲得完整提示詞;標準層級(GPT-4o、Gemini、DeepSeek)停用思考模式;基礎層級(MiniMax、Qwen、Llama、Mistral)獲得精簡巢狀 JSON 提示詞,確保最大可靠性。 @@ -223,22 +225,32 @@ docker build --target full -t openpencil-full . ## 專案結構 ```text -src/ - canvas/ CanvasKit/Skia 引擎 — 繪圖、同步、版面配置、參考線、鋼筆工具 - components/ React UI — 編輯器、面板、共用對話框、圖示 - services/ai/ AI 聊天、編排器、設計生成、串流處理 - services/figma/ Figma .fig 二進位檔案匯入管線 - services/codegen React+Tailwind 和 HTML+CSS 程式碼生成器 - stores/ Zustand — 畫布、文件、頁面、歷程、AI、設定 - variables/ 設計令牌解析與參照管理 - mcp/ 供外部 CLI 整合使用的 MCP 伺服器工具 - uikit/ 可重複使用元件套件系統 -server/ - api/ai/ Nitro API — 串流聊天、生成、驗證 - utils/ Claude CLI、OpenCode、Codex、Copilot 客戶端封裝 -electron/ - main.ts 視窗、Nitro 子處理序、原生選單、自動更新 - preload.ts IPC 橋接 +openpencil/ +├── apps/ +│ ├── web/ TanStack Start Web 應用程式 +│ │ ├── src/ +│ │ │ ├── canvas/ CanvasKit/Skia 引擎 — 繪圖、同步、版面配置 +│ │ │ ├── components/ React UI — 編輯器、面板、共用對話框、圖示 +│ │ │ ├── services/ai/ AI 聊天、編排器、設計生成、串流處理 +│ │ │ ├── stores/ Zustand — 畫布、文件、頁面、歷程、AI +│ │ │ ├── mcp/ 供外部 CLI 整合使用的 MCP 伺服器工具 +│ │ │ ├── hooks/ 鍵盤快捷鍵、檔案拖放、Figma 貼上 +│ │ │ └── uikit/ 可重複使用元件套件系統 +│ │ └── server/ +│ │ ├── api/ai/ Nitro API — 串流聊天、生成、驗證 +│ │ └── utils/ Claude CLI、OpenCode、Codex、Copilot 客戶端封裝 +│ └── desktop/ Electron 桌面應用程式 +│ ├── main.ts 視窗、Nitro 子處理序、原生選單、自動更新 +│ ├── ipc-handlers.ts 原生檔案對話框、主題同步、偏好設定 IPC +│ └── preload.ts IPC 橋接 +├── packages/ +│ ├── pen-types/ PenDocument 模型型別定義 +│ ├── pen-core/ 文件樹操作、版面配置引擎、變數 +│ ├── pen-codegen/ 程式碼生成器(React、HTML、Vue、Flutter...) +│ ├── pen-figma/ Figma .fig 檔案解析器與轉換器 +│ ├── pen-renderer/ 獨立 CanvasKit/Skia 渲染器 +│ └── pen-sdk/ 整合 SDK(重新匯出所有套件) +└── .githooks/ Pre-commit 版本號同步(從分支名稱) ``` ## 鍵盤快捷鍵 @@ -266,6 +278,7 @@ bun --bun run dev # 開發伺服器(連接埠 3000) bun --bun run build # 正式版建置 bun --bun run test # 執行測試(Vitest) npx tsc --noEmit # 型別檢查 +bun run bump # 在所有 package.json 間同步版本號 bun run electron:dev # Electron 開發模式 bun run electron:build # Electron 封裝 ``` @@ -275,10 +288,11 @@ bun run electron:build # Electron 封裝 歡迎貢獻!請查閱 [CLAUDE.md](./CLAUDE.md) 了解架構細節和程式碼風格。 1. Fork 並複製存放庫 -2. 建立分支:`git checkout -b feat/my-feature` -3. 執行檢查:`npx tsc --noEmit && bun --bun run test` -4. 使用 [Conventional Commits](https://www.conventionalcommits.org/) 提交:`feat(canvas): add rotation snapping` -5. 向 `main` 分支發起 PR +2. 設定版本同步:`git config core.hooksPath .githooks` +3. 建立分支:`git checkout -b feat/my-feature` +4. 執行檢查:`npx tsc --noEmit && bun --bun run test` +5. 使用 [Conventional Commits](https://www.conventionalcommits.org/) 提交:`feat(canvas): add rotation snapping` +6. 向 `main` 分支發起 PR ## 路線圖 @@ -290,6 +304,7 @@ bun run electron:build # Electron 封裝 - [x] Figma `.fig` 匯入 - [x] 布林運算(聯集、減去、交集) - [x] 多模型能力設定檔 +- [x] Monorepo 重構,支援可重複使用套件 - [ ] 協同編輯 - [ ] 外掛程式系統 @@ -302,12 +317,11 @@ bun run electron:build # Electron 封裝 ## 社群 - Discord + Discord 加入我們的 Discord — 提問、分享設計、提出功能建議。 - ## Star History diff --git a/README.zh.md b/README.zh.md index 48c6b314..2b477801 100644 --- a/README.zh.md +++ b/README.zh.md @@ -1,5 +1,5 @@

- OpenPencil + OpenPencil

OpenPencil

@@ -55,7 +55,7 @@ ### 🧠 多模型智能 -自动适配每个模型的能力。Claude 获得完整提示词和思考模式;GPT-4o/Gemini 关闭思考模式;小模型(MiniMax、通义千问、Llama)使用简化提示词以确保输出可靠性。 +自动适配每个模型的能力。Claude 获得完整提示词和思考模式;GPT-4o/Gemini 关闭思考模式;小模型(MiniMax、Qwen、Llama)使用简化提示词以确保输出可靠性。 @@ -102,9 +102,9 @@ bun run electron:dev > **前置条件:** [Bun](https://bun.sh/) >= 1.0 以及 [Node.js](https://nodejs.org/) >= 18 -### Docker 部署 +### Docker -提供多个镜像变体,按需选择: +提供多个镜像变体 — 按需选择: | 镜像 | 大小 | 包含 | | --- | --- | --- | @@ -113,6 +113,7 @@ bun run electron:dev | `openpencil-codex:latest` | — | + Codex CLI | | `openpencil-opencode:latest` | — | + OpenCode CLI | | `openpencil-copilot:latest` | — | + GitHub Copilot CLI | +| `openpencil-gemini:latest` | — | + Gemini CLI | | `openpencil-full:latest` | ~1 GB | 全部 CLI 工具 | **运行(仅 Web):** @@ -123,7 +124,7 @@ docker run -d -p 3000:3000 ghcr.io/zseven-w/openpencil:latest **运行 AI CLI(以 Claude Code 为例):** -AI 聊天依赖 Claude CLI 的 OAuth 登录,使用 Docker volume 持久化登录状态: +AI 聊天依赖 Claude CLI 的 OAuth 登录。使用 Docker volume 持久化登录会话: ```bash # 第一步 — 登录(仅需一次) @@ -167,8 +168,9 @@ docker build --target full -t openpencil-full . | **Codex CLI** | 在 Agent 设置中连接(`Cmd+,`) | | **OpenCode** | 在 Agent 设置中连接(`Cmd+,`) | | **GitHub Copilot** | 运行 `copilot login` 后在 Agent 设置中连接(`Cmd+,`) | +| **Gemini CLI** | 在 Agent 设置中连接(`Cmd+,`) | -**模型能力配置** — 自动根据模型层级适配提示词、思考模式和超时时间。完整层级模型(Claude)获得完整提示词;标准层级模型(GPT-4o、Gemini、DeepSeek)关闭思考模式;基础层级模型(MiniMax、通义千问、Llama、Mistral)使用简化的嵌套 JSON 提示词以确保最大可靠性。 +**模型能力配置** — 自动根据模型层级适配提示词、思考模式和超时时间。完整层级模型(Claude)获得完整提示词;标准层级模型(GPT-4o、Gemini、DeepSeek)关闭思考模式;基础层级模型(MiniMax、Qwen、Llama、Mistral)使用简化的嵌套 JSON 提示词以确保最大可靠性。 **MCP 服务器** - 内置 MCP 服务器 — 一键安装到 Claude Code / Codex / Gemini / OpenCode / Kiro / Copilot CLI @@ -181,7 +183,6 @@ docker build --target full -t openpencil-full . **代码生成** - React + Tailwind CSS、HTML + CSS、CSS Variables - Vue、Svelte、Flutter、SwiftUI、Jetpack Compose、React Native -- 从设计令牌生成 CSS Variables ## 功能特性 @@ -224,22 +225,32 @@ docker build --target full -t openpencil-full . ## 项目结构 ```text -src/ - canvas/ CanvasKit/Skia 引擎 — 绘图、同步、布局、参考线、钢笔工具 - components/ React UI — 编辑器、面板、共享对话框、图标 - services/ai/ AI 聊天、编排器、设计生成、流式处理 - services/figma/ Figma .fig 二进制文件导入流水线 - services/codegen 多平台代码生成器(React、HTML、Vue、Svelte、Flutter、SwiftUI、Compose、React Native) - stores/ Zustand — 画布、文档、页面、历史、AI、设置 - variables/ 设计令牌解析与引用管理 - mcp/ 供外部 CLI 集成使用的 MCP 服务器工具 - uikit/ 可复用组件套件系统 -server/ - api/ai/ Nitro API — 流式聊天、生成、验证 - utils/ Claude CLI、OpenCode、Codex、Copilot 客户端封装 -electron/ - main.ts 窗口、Nitro 子进程、原生菜单、自动更新 - preload.ts IPC 桥接 +openpencil/ +├── apps/ +│ ├── web/ TanStack Start Web 应用 +│ │ ├── src/ +│ │ │ ├── canvas/ CanvasKit/Skia 引擎 — 绘图、同步、布局 +│ │ │ ├── components/ React UI — 编辑器、面板、共享对话框、图标 +│ │ │ ├── services/ai/ AI 聊天、编排器、设计生成、流式处理 +│ │ │ ├── stores/ Zustand — 画布、文档、页面、历史、AI +│ │ │ ├── mcp/ 供外部 CLI 集成使用的 MCP 服务器工具 +│ │ │ ├── hooks/ 键盘快捷键、文件拖放、Figma 粘贴 +│ │ │ └── uikit/ 可复用组件套件系统 +│ │ └── server/ +│ │ ├── api/ai/ Nitro API — 流式聊天、生成、验证 +│ │ └── utils/ Claude CLI、OpenCode、Codex、Copilot 客户端封装 +│ └── desktop/ Electron 桌面应用 +│ ├── main.ts 窗口、Nitro 子进程、原生菜单、自动更新 +│ ├── ipc-handlers.ts 原生文件对话框、主题同步、偏好设置 IPC +│ └── preload.ts IPC 桥接 +├── packages/ +│ ├── pen-types/ PenDocument 模型类型定义 +│ ├── pen-core/ 文档树操作、布局引擎、变量 +│ ├── pen-codegen/ 代码生成器(React、HTML、Vue、Flutter 等) +│ ├── pen-figma/ Figma .fig 文件解析与转换 +│ ├── pen-renderer/ 独立 CanvasKit/Skia 渲染器 +│ └── pen-sdk/ 聚合 SDK(重新导出所有包) +└── .githooks/ 预提交钩子:从分支名同步版本号 ``` ## 键盘快捷键 @@ -267,6 +278,7 @@ bun --bun run dev # 开发服务器(端口 3000) bun --bun run build # 生产构建 bun --bun run test # 运行测试(Vitest) npx tsc --noEmit # 类型检查 +bun run bump # 同步所有 package.json 的版本号 bun run electron:dev # Electron 开发模式 bun run electron:build # Electron 打包 ``` @@ -276,10 +288,11 @@ bun run electron:build # Electron 打包 欢迎贡献!请查阅 [CLAUDE.md](./CLAUDE.md) 了解架构细节和代码风格。 1. Fork 并克隆仓库 -2. 创建分支:`git checkout -b feat/my-feature` -3. 运行检查:`npx tsc --noEmit && bun --bun run test` -4. 使用 [Conventional Commits](https://www.conventionalcommits.org/) 提交:`feat(canvas): add rotation snapping` -5. 向 `main` 分支发起 PR +2. 设置版本同步:`git config core.hooksPath .githooks` +3. 创建分支:`git checkout -b feat/my-feature` +4. 运行检查:`npx tsc --noEmit && bun --bun run test` +5. 使用 [Conventional Commits](https://www.conventionalcommits.org/) 提交:`feat(canvas): add rotation snapping` +6. 向 `main` 分支发起 PR ## 路线图 @@ -291,6 +304,7 @@ bun run electron:build # Electron 打包 - [x] Figma `.fig` 导入 - [x] 布尔运算(合并、减去、相交) - [x] 多模型能力配置 +- [x] Monorepo 重构与可复用包 - [ ] 协同编辑 - [ ] 插件系统 @@ -303,16 +317,11 @@ bun run electron:build # Electron 打包 ## 社区
- Discord + Discord 加入我们的 Discord — 提问、分享设计、提出功能建议。 -**飞书交流群** - -飞书交流群 - - ## Star History diff --git a/apps/desktop/CLAUDE.md b/apps/desktop/CLAUDE.md new file mode 100644 index 00000000..cacb0697 --- /dev/null +++ b/apps/desktop/CLAUDE.md @@ -0,0 +1,37 @@ +# Desktop App + +Electron desktop app for macOS, Windows, and Linux. + +## Files + +- **`main.ts`** — Main process: window creation, Nitro server fork, preferences, `.op` file association handling (`open-file` event on macOS, CLI args + single-instance lock on Windows/Linux) +- **`ipc-handlers.ts`** — IPC handler setup: native file dialogs (`dialog:openFile`, `dialog:saveFile`, `dialog:saveToPath`), theme sync for title bar overlay, renderer preferences (replaces origin-scoped localStorage), auto-updater IPC +- **`preload.ts`** — Context bridge for renderer <-> main IPC (file dialogs, menu actions, updater state, `onOpenFile`/`readFile` for file association) +- **`app-menu.ts`** — Native application menu configuration (File, Edit, View, Help) +- **`auto-updater.ts`** — Auto-updater: checks GitHub Releases on startup and periodically +- **`constants.ts`** — Electron-specific constants (port, window dimensions, platform padding) +- **`logger.ts`** — Main process logging +- **`dev.ts`** — Dev workflow: starts Vite -> waits for port 3000 -> compiles MCP -> compiles Electron -> launches Electron + +## Build Flow + +```text +BUILD_TARGET=electron bun run build + -> bun run electron:compile (esbuild electron/ to out/desktop/) + -> bun run mcp:compile + -> npx electron-builder --config apps/desktop/electron-builder.yml +``` + +- **`electron-builder.yml`** — Packaging config: macOS (dmg/zip), Windows (nsis/portable), Linux (AppImage/deb), `.op` file association +- **`build/`** — Platform icons (.icns, .ico, .png) +- In production, Nitro server is forked as a child process on a random port; Electron loads `http://127.0.0.1:{port}/editor` + +## File Association + +`.op` files are registered as OpenPencil documents via `fileAssociations` in `electron-builder.yml`: +- macOS: `open-file` app event handles double-click/drag +- Windows/Linux: `requestSingleInstanceLock` + `second-instance` event forwards CLI args to existing window + +## Auto-updater + +Checks GitHub Releases on startup and every hour. `update-ready-banner.tsx` (in web app) shows download progress and "Restart & Install" prompt. diff --git a/electron/app-menu.ts b/apps/desktop/app-menu.ts similarity index 100% rename from electron/app-menu.ts rename to apps/desktop/app-menu.ts diff --git a/electron/auto-updater.ts b/apps/desktop/auto-updater.ts similarity index 100% rename from electron/auto-updater.ts rename to apps/desktop/auto-updater.ts diff --git a/build/icon.icns b/apps/desktop/build/icon.icns similarity index 100% rename from build/icon.icns rename to apps/desktop/build/icon.icns diff --git a/build/icon.ico b/apps/desktop/build/icon.ico similarity index 100% rename from build/icon.ico rename to apps/desktop/build/icon.ico diff --git a/build/icon.png b/apps/desktop/build/icon.png similarity index 100% rename from build/icon.png rename to apps/desktop/build/icon.png diff --git a/electron/constants.ts b/apps/desktop/constants.ts similarity index 100% rename from electron/constants.ts rename to apps/desktop/constants.ts diff --git a/scripts/electron-dev.ts b/apps/desktop/dev.ts similarity index 86% rename from scripts/electron-dev.ts rename to apps/desktop/dev.ts index 928c6272..9ac910c2 100644 --- a/scripts/electron-dev.ts +++ b/apps/desktop/dev.ts @@ -11,7 +11,8 @@ import { spawn, execSync, type ChildProcess } from 'node:child_process' import { build } from 'esbuild' import { join } from 'node:path' -const ROOT = join(import.meta.dirname, '..') +const DESKTOP_DIR = import.meta.dirname +const ROOT = join(DESKTOP_DIR, '..', '..') const VITE_DEV_PORT = 3000 // --------------------------------------------------------------------------- @@ -42,7 +43,7 @@ async function compileElectron(): Promise { sourcemap: true, external: ['electron'], target: 'node20', - outdir: join(ROOT, 'electron-dist'), + outdir: join(ROOT, 'out', 'desktop'), outExtension: { '.js': '.cjs' }, format: 'cjs' as const, } @@ -50,11 +51,11 @@ async function compileElectron(): Promise { await Promise.all([ build({ ...common, - entryPoints: [join(ROOT, 'electron', 'main.ts')], + entryPoints: [join(DESKTOP_DIR, 'main.ts')], }), build({ ...common, - entryPoints: [join(ROOT, 'electron', 'preload.ts')], + entryPoints: [join(DESKTOP_DIR, 'preload.ts')], }), ]) @@ -77,7 +78,6 @@ async function main(): Promise { // Ensure cleanup on exit const cleanup = () => { if (process.platform === 'win32' && vite.pid) { - // SIGTERM is unreliable on Windows; use taskkill for proper tree-kill try { execSync(`taskkill /pid ${vite.pid} /T /F`, { stdio: 'ignore' }) } catch { /* ignore */ } @@ -102,10 +102,11 @@ async function main(): Promise { sourcemap: true, target: 'node20', format: 'cjs', - entryPoints: [join(ROOT, 'src', 'mcp', 'server.ts')], - outfile: join(ROOT, 'dist', 'mcp-server.cjs'), - alias: { '@': join(ROOT, 'src') }, + entryPoints: [join(ROOT, 'apps', 'web', 'src', 'mcp', 'server.ts')], + outfile: join(ROOT, 'out', 'mcp-server.cjs'), + alias: { '@': join(ROOT, 'apps', 'web', 'src') }, define: { 'import.meta.env': '{}' }, + external: ['canvas', 'paper'], }) console.log('[electron-dev] MCP server compiled') @@ -114,7 +115,7 @@ async function main(): Promise { // 4. Launch Electron console.log('[electron-dev] Starting Electron...') const electronBin = join(ROOT, 'node_modules', '.bin', 'electron') - const electron = spawn(electronBin, [join(ROOT, 'electron-dist', 'main.cjs')], { + const electron = spawn(electronBin, [join(ROOT, 'out', 'desktop', 'main.cjs')], { cwd: ROOT, stdio: 'inherit', env: { ...process.env }, diff --git a/electron-builder.yml b/apps/desktop/electron-builder.yml similarity index 80% rename from electron-builder.yml rename to apps/desktop/electron-builder.yml index 179f42cb..c0ec0db9 100644 --- a/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -3,24 +3,27 @@ productName: OpenPencil copyright: Copyright (c) 2024-2026 OpenPencil contributors directories: - output: dist-electron - buildResources: build + output: out/release + buildResources: apps/desktop/build files: - - electron-dist/**/* + - from: out/desktop + to: . + filter: + - "**/*" - "!node_modules" extraResources: - - from: .output/server + - from: out/web/server to: server - - from: .output/public + - from: out/web/public to: public - - from: dist/mcp-server.cjs + - from: out/mcp-server.cjs to: mcp-server.cjs mac: category: public.app-category.graphics-design - icon: build/icon.icns + icon: apps/desktop/build/icon.icns artifactName: "${productName}-${version}-${arch}-mac.${ext}" target: - dmg @@ -33,7 +36,7 @@ dmg: title: "${productName} ${version}" win: - icon: build/icon.ico + icon: apps/desktop/build/icon.ico artifactName: "${productName}-${version}-${arch}-win.${ext}" target: - nsis @@ -48,7 +51,7 @@ nsis: createStartMenuShortcut: true linux: - icon: build/icon.png + icon: apps/desktop/build/icon.png category: Graphics artifactName: "${productName}-${version}-${arch}-linux.${ext}" desktop: @@ -72,6 +75,6 @@ fileAssociations: description: OpenPencil Design File mimeType: application/x-openpencil role: Editor - icon: build/icon + icon: apps/desktop/build/icon asar: true diff --git a/apps/desktop/ipc-handlers.ts b/apps/desktop/ipc-handlers.ts new file mode 100644 index 00000000..88dc158c --- /dev/null +++ b/apps/desktop/ipc-handlers.ts @@ -0,0 +1,163 @@ +import { + ipcMain, + dialog, + type BrowserWindow, +} from 'electron' +import { resolve, extname, sep } from 'node:path' +import { readFile, writeFile } from 'node:fs/promises' +import { app } from 'electron' + +import { + getUpdaterState, + checkForAppUpdates, + quitAndInstall, + getAutoUpdateEnabled, + setAutoUpdateEnabled, + setUpdaterState, + clearUpdateTimer, + startUpdateTimer, +} from './auto-updater' +import { getLogDir } from './logger' + +interface IpcDeps { + getMainWindow: () => BrowserWindow | null + getPendingFilePath: () => string | null + clearPendingFilePath: () => void + prefsCache: Record + schedulePrefsWrite: () => void + writeAppSettings: (patch: { autoUpdate?: boolean }) => Promise +} + +export function setupIPC(deps: IpcDeps): void { + const { getMainWindow, getPendingFilePath, clearPendingFilePath, prefsCache, schedulePrefsWrite, writeAppSettings } = deps + + ipcMain.handle('dialog:openFile', async () => { + const mainWindow = getMainWindow() + if (!mainWindow) return null + const result = await dialog.showOpenDialog(mainWindow, { + title: 'Open .op file', + filters: [{ name: 'OpenPencil Files', extensions: ['op', 'pen'] }], + properties: ['openFile'], + }) + if (result.canceled || result.filePaths.length === 0) return null + const filePath = result.filePaths[0] + const content = await readFile(filePath, 'utf-8') + return { filePath, content } + }) + + ipcMain.handle( + 'dialog:saveFile', + async (_event, payload: { content: string; defaultPath?: string }) => { + const mainWindow = getMainWindow() + if (!mainWindow) return null + const result = await dialog.showSaveDialog(mainWindow, { + title: 'Save .op file', + defaultPath: payload.defaultPath, + filters: [{ name: 'OpenPencil Files', extensions: ['op'] }], + }) + if (result.canceled || !result.filePath) return null + await writeFile(result.filePath, payload.content, 'utf-8') + return result.filePath + }, + ) + + ipcMain.handle( + 'dialog:saveToPath', + async (_event, payload: { filePath: string; content: string }) => { + const resolved = resolve(payload.filePath) + if (resolved.includes('\0')) { + throw new Error('Invalid file path') + } + const ext = extname(resolved).toLowerCase() + if (ext !== '.op' && ext !== '.pen') { + throw new Error('Only .op and .pen file extensions are allowed') + } + const allowedRoots = [app.getPath('home'), app.getPath('temp')] + const inAllowedDir = allowedRoots.some( + (root) => resolved === root || resolved.startsWith(root + sep), + ) + if (!inAllowedDir) { + throw new Error('File path must be within the user home or temp directory') + } + await writeFile(resolved, payload.content, 'utf-8') + return resolved + }, + ) + + ipcMain.handle('file:getPending', () => { + const filePath = getPendingFilePath() + if (filePath) { + clearPendingFilePath() + return filePath + } + return null + }) + + ipcMain.handle('file:read', async (_event, filePath: string) => { + const resolved = resolve(filePath) + const ext = extname(resolved).toLowerCase() + if (ext !== '.op' && ext !== '.pen') return null + try { + const content = await readFile(resolved, 'utf-8') + return { filePath: resolved, content } + } catch { + return null + } + }) + + // Theme sync for Windows/Linux title bar overlay + ipcMain.handle( + 'theme:set', + (_event, theme: 'dark' | 'light', colors?: { bg: string; fg: string }) => { + const mainWindow = getMainWindow() + if (!mainWindow || mainWindow.isDestroyed()) return + const isWinOrLinux = process.platform === 'win32' || process.platform === 'linux' + if (!isWinOrLinux) return + const isLinux = process.platform === 'linux' + const fallbackBg = theme === 'dark' ? '#111' : '#fff' + const fallbackFg = theme === 'dark' ? '#d4d4d8' : '#3f3f46' + mainWindow.setTitleBarOverlay({ + color: isLinux ? (colors?.bg || fallbackBg) : 'rgba(0,0,0,0)', + symbolColor: colors?.fg || fallbackFg, + }) + }, + ) + + // Renderer preferences + ipcMain.handle('prefs:getAll', () => ({ ...prefsCache })) + + ipcMain.handle('prefs:set', (_event, key: string, value: string) => { + prefsCache[key] = value + schedulePrefsWrite() + }) + + ipcMain.handle('prefs:remove', (_event, key: string) => { + delete prefsCache[key] + schedulePrefsWrite() + }) + + ipcMain.handle('log:getDir', () => getLogDir()) + + // Updater IPC + ipcMain.handle('updater:getState', () => getUpdaterState()) + ipcMain.handle('updater:checkForUpdates', async () => { + await checkForAppUpdates(true) + return getUpdaterState() + }) + ipcMain.handle('updater:quitAndInstall', () => quitAndInstall()) + ipcMain.handle('updater:getAutoCheck', () => getAutoUpdateEnabled()) + + ipcMain.handle('updater:setAutoCheck', async (_event, enabled: boolean) => { + setAutoUpdateEnabled(enabled) + await writeAppSettings({ autoUpdate: enabled }) + + if (enabled) { + startUpdateTimer() + setUpdaterState({ status: 'idle' }) + } else { + clearUpdateTimer() + setUpdaterState({ status: 'disabled' }) + } + return enabled + }) +} diff --git a/electron/logger.ts b/apps/desktop/logger.ts similarity index 100% rename from electron/logger.ts rename to apps/desktop/logger.ts diff --git a/electron/main.ts b/apps/desktop/main.ts similarity index 80% rename from electron/main.ts rename to apps/desktop/main.ts index 9c5b689d..77ddc3e2 100644 --- a/electron/main.ts +++ b/apps/desktop/main.ts @@ -8,7 +8,7 @@ import { import { execSync } from 'node:child_process' import { fork, type ChildProcess } from 'node:child_process' import { createServer } from 'node:net' -import { join, resolve, extname, sep } from 'node:path' +import { join, extname } from 'node:path' import { homedir } from 'node:os' import { readFile, writeFile, mkdir, unlink } from 'node:fs/promises' @@ -33,16 +33,12 @@ import { import { setupAutoUpdater, broadcastUpdaterState, - getUpdaterState, setUpdaterState, - checkForAppUpdates, clearUpdateTimer, - startUpdateTimer, - quitAndInstall, - getAutoUpdateEnabled, setAutoUpdateEnabled, } from './auto-updater' -import { initLogger, log, getLogDir } from './logger' +import { initLogger, log } from './logger' +import { setupIPC } from './ipc-handlers' let mainWindow: BrowserWindow | null = null let nitroProcess: ChildProcess | null = null @@ -448,143 +444,91 @@ function createWindow(): void { mainWindow.webContents.openDevTools({ mode: 'detach' }) } + // --------------------------------------------------------------------------- + // Unsaved-changes close confirmation + // --------------------------------------------------------------------------- + let forceClose = false + + mainWindow.on('close', (event) => { + if (forceClose || !mainWindow || mainWindow.isDestroyed()) return + + // preventDefault must be called synchronously — check dirty state after + event.preventDefault() + + const win = mainWindow + win.webContents + .executeJavaScript( + '({ dirty: window.__documentIsDirty === true,' + + ' message: typeof window.__i18nT === "function" ? window.__i18nT("topbar.closeConfirmMessage") : "",' + + ' detail: typeof window.__i18nT === "function" ? window.__i18nT("topbar.closeConfirmDetail") : "",' + + ' save: typeof window.__i18nT === "function" ? window.__i18nT("common.save") : "",' + + ' dontSave: typeof window.__i18nT === "function" ? window.__i18nT("topbar.dontSave") : "",' + + ' cancel: typeof window.__i18nT === "function" ? window.__i18nT("common.cancel") : "" })', + ) + .then((result: { dirty: boolean; message: string; detail: string; save: string; dontSave: string; cancel: string }) => { + if (!result.dirty) { + // Not dirty — allow close + forceClose = true + win.close() + return + } + + // Show native save dialog with i18n strings + return dialog + .showMessageBox(win, { + type: 'question', + buttons: [ + result.save || 'Save', + result.dontSave || "Don't Save", + result.cancel || 'Cancel', + ], + defaultId: 0, + cancelId: 2, + message: result.message || 'Do you want to save changes before closing?', + detail: result.detail || 'Your changes will be lost if you don\'t save them.', + }) + .then(({ response }) => { + if (response === 0) { + // Save — tell renderer to save then confirm close + win.webContents.send('menu:action', 'save-and-close') + } else if (response === 1) { + // Don't Save — force close + forceClose = true + win.close() + } + // Cancel (response === 2) — do nothing, window stays open + }) + }) + .catch(() => { + // Page not loaded or crashed — allow close + forceClose = true + win.close() + }) + }) + + // Renderer confirms save completed → force close + ipcMain.on('window:confirmClose', () => { + forceClose = true + mainWindow?.close() + }) + mainWindow.on('closed', () => { mainWindow = null }) } // --------------------------------------------------------------------------- -// IPC: native file dialogs & updater +// IPC: native file dialogs & updater (extracted to ipc-handlers.ts) // --------------------------------------------------------------------------- -function setupIPC(): void { - ipcMain.handle('dialog:openFile', async () => { - if (!mainWindow) return null - const result = await dialog.showOpenDialog(mainWindow, { - title: 'Open .op file', - filters: [{ name: 'OpenPencil Files', extensions: ['op', 'pen'] }], - properties: ['openFile'], - }) - if (result.canceled || result.filePaths.length === 0) return null - const filePath = result.filePaths[0] - const content = await readFile(filePath, 'utf-8') - return { filePath, content } - }) - - ipcMain.handle( - 'dialog:saveFile', - async (_event, payload: { content: string; defaultPath?: string }) => { - if (!mainWindow) return null - const result = await dialog.showSaveDialog(mainWindow, { - title: 'Save .op file', - defaultPath: payload.defaultPath, - filters: [{ name: 'OpenPencil Files', extensions: ['op'] }], - }) - if (result.canceled || !result.filePath) return null - await writeFile(result.filePath, payload.content, 'utf-8') - return result.filePath - }, - ) - - ipcMain.handle( - 'dialog:saveToPath', - async (_event, payload: { filePath: string; content: string }) => { - const resolved = resolve(payload.filePath) - if (resolved.includes('\0')) { - throw new Error('Invalid file path') - } - const ext = extname(resolved).toLowerCase() - if (ext !== '.op' && ext !== '.pen') { - throw new Error('Only .op and .pen file extensions are allowed') - } - // Directory allowlist: only allow writes under user home or OS temp - const allowedRoots = [app.getPath('home'), app.getPath('temp')] - const inAllowedDir = allowedRoots.some( - (root) => resolved === root || resolved.startsWith(root + sep), - ) - if (!inAllowedDir) { - throw new Error('File path must be within the user home or temp directory') - } - await writeFile(resolved, payload.content, 'utf-8') - return resolved - }, - ) - - ipcMain.handle('file:getPending', () => { - if (pendingFilePath) { - const filePath = pendingFilePath - pendingFilePath = null - return filePath - } - return null - }) - - ipcMain.handle('file:read', async (_event, filePath: string) => { - const resolved = resolve(filePath) - const ext = extname(resolved).toLowerCase() - if (ext !== '.op' && ext !== '.pen') return null - try { - const content = await readFile(resolved, 'utf-8') - return { filePath: resolved, content } - } catch { - return null - } - }) - - // Theme sync for Windows/Linux title bar overlay - ipcMain.handle( - 'theme:set', - (_event, theme: 'dark' | 'light', colors?: { bg: string; fg: string }) => { - if (!mainWindow || mainWindow.isDestroyed()) return - const isWinOrLinux = process.platform === 'win32' || process.platform === 'linux' - if (!isWinOrLinux) return - const isLinux = process.platform === 'linux' - const fallbackBg = theme === 'dark' ? '#111' : '#fff' - const fallbackFg = theme === 'dark' ? '#d4d4d8' : '#3f3f46' - mainWindow.setTitleBarOverlay({ - // Windows supports transparent overlay; Linux uses actual CSS card color - color: isLinux ? (colors?.bg || fallbackBg) : 'rgba(0,0,0,0)', - symbolColor: colors?.fg || fallbackFg, - }) - }, - ) - - // Generic renderer preferences (replaces localStorage which is origin-scoped - // and lost when Nitro server restarts on a different random port) - ipcMain.handle('prefs:getAll', () => ({ ...prefsCache })) - - ipcMain.handle('prefs:set', (_event, key: string, value: string) => { - prefsCache[key] = value - schedulePrefsWrite() - }) - - ipcMain.handle('prefs:remove', (_event, key: string) => { - delete prefsCache[key] - schedulePrefsWrite() - }) - - ipcMain.handle('log:getDir', () => getLogDir()) - - ipcMain.handle('updater:getState', () => getUpdaterState()) - ipcMain.handle('updater:checkForUpdates', async () => { - await checkForAppUpdates(true) - return getUpdaterState() - }) - ipcMain.handle('updater:quitAndInstall', () => quitAndInstall()) - ipcMain.handle('updater:getAutoCheck', () => getAutoUpdateEnabled()) - - ipcMain.handle('updater:setAutoCheck', async (_event, enabled: boolean) => { - setAutoUpdateEnabled(enabled) - await writeAppSettings({ autoUpdate: enabled }) - - if (enabled) { - startUpdateTimer() - setUpdaterState({ status: 'idle' }) - } else { - clearUpdateTimer() - setUpdaterState({ status: 'disabled' }) - } - return enabled +function initIPC(): void { + setupIPC({ + getMainWindow: () => mainWindow, + getPendingFilePath: () => pendingFilePath, + clearPendingFilePath: () => { pendingFilePath = null }, + prefsCache, + schedulePrefsWrite, + writeAppSettings, }) } @@ -651,7 +595,7 @@ app.on('ready', async () => { await initLogger(app.getPath('userData')) fixPath() await loadPrefs() - setupIPC() + initIPC() buildAppMenu() if (!isDev) { diff --git a/apps/desktop/package.json b/apps/desktop/package.json new file mode 100644 index 00000000..3a165265 --- /dev/null +++ b/apps/desktop/package.json @@ -0,0 +1,6 @@ +{ + "name": "@zseven-w/desktop", + "version": "0.5.0", + "private": true, + "type": "module" +} diff --git a/electron/preload.ts b/apps/desktop/preload.ts similarity index 97% rename from electron/preload.ts rename to apps/desktop/preload.ts index 27487ca4..e95a4c00 100644 --- a/electron/preload.ts +++ b/apps/desktop/preload.ts @@ -36,6 +36,7 @@ export interface ElectronAPI { getPreferences: () => Promise> setPreference: (key: string, value: string) => Promise removePreference: (key: string) => Promise + confirmClose: () => void updater: { getState: () => Promise checkForUpdates: () => Promise @@ -90,6 +91,8 @@ const api: ElectronAPI = { getPendingFile: () => ipcRenderer.invoke('file:getPending'), + confirmClose: () => ipcRenderer.send('window:confirmClose'), + getLogDir: () => ipcRenderer.invoke('log:getDir'), updater: { diff --git a/electron/tsconfig.json b/apps/desktop/tsconfig.json similarity index 89% rename from electron/tsconfig.json rename to apps/desktop/tsconfig.json index 0c951eab..0fdbff59 100644 --- a/electron/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -3,7 +3,7 @@ "target": "ES2022", "module": "CommonJS", "moduleResolution": "node", - "outDir": "../electron-dist", + "outDir": "../../out/desktop", "rootDir": ".", "strict": true, "esModuleInterop": true, diff --git a/apps/web/CLAUDE.md b/apps/web/CLAUDE.md new file mode 100644 index 00000000..9c2111de --- /dev/null +++ b/apps/web/CLAUDE.md @@ -0,0 +1,116 @@ +# Web App + +TanStack Start full-stack React app (Vite + Nitro). Routes in `src/routes/`, auto-generated tree in `src/routeTree.gen.ts` (do not edit). + +- `/` — Landing page +- `/editor` — Main design editor + +## Canvas Engine (`src/canvas/`) + +14 files + `skia/` subdir with 14 files. + +### CanvasKit/Skia Architecture + +- **GPU-accelerated WASM rendering** — CanvasKit (Skia compiled to WASM) renders all canvas content via WebGL surface +- **SkiaEngine class** (`skia-engine.ts`) is the core: owns the render loop, viewport transforms, node flattening, and `SpatialIndex` for hit testing +- **Dirty-flag rendering** — `markDirty()` schedules a `requestAnimationFrame` redraw; no continuous rendering loop +- **Node flattening** — `syncFromDocument()` walks the PenDocument tree, resolves auto-layout positions via layout engine, and produces flat `RenderNode[]` with absolute coordinates +- **SpatialIndex** (`skia-hit-test.ts`) — R-tree backed spatial queries for `hitTest()` (click) and `searchRect()` (marquee selection) +- **Coordinate conversion** — `screenToScene()` / `sceneToScreen()` in `skia-viewport.ts` handle viewport ↔ scene transforms +- **Event handling** — mouse/keyboard events managed by `SkiaInteractionManager` (`skia-interaction.ts`); hit testing for resize/rotate/arc handles in `skia-hit-handlers.ts`; `skia-canvas.tsx` is the React component (lifecycle, sync, rendering) +- **Parent-child transforms** — nodes are flattened to absolute coordinates; transforms propagate to descendants during drag/scale/rotate + +### `skia/` Files + +- `skia-canvas.tsx` — React component: lifecycle, sync effects, wheel zoom, text editing overlay; delegates interaction to `SkiaInteractionManager` +- `skia-interaction.ts` — `SkiaInteractionManager` class: all mouse/keyboard interaction state and handlers (select, drag, resize, rotate, draw, marquee, pen tool, arc editing, hover cursor) +- `skia-hit-handlers.ts` — Hit test functions: `hitTestHandle` (resize), `hitTestRotation` (rotation zone), `hitTestArcHandle` (ellipse arc) +- `skia-engine.ts` — Core rendering engine: `SkiaEngine` class, `syncFromDocument()`, viewport, node flattening, zoom/pan, dirty-flag loop +- `skia-renderer.ts` — GPU draw calls: shapes, text, paths, images, selection handles, guides, agent indicators +- `skia-init.ts` — CanvasKit WASM loader with CDN fallback +- `skia-hit-test.ts` — `SpatialIndex` R-tree for spatial queries +- `skia-viewport.ts` — Viewport math +- `skia-paint-utils.ts` — Color parsing, gradient creation, text line wrapping +- `skia-path-utils.ts` — SVG path to CanvasKit Path conversion +- `skia-image-loader.ts` — Async image loading and caching +- `skia-overlays.ts` — Selection overlays, hover highlights, dimension labels +- `skia-pen-tool.ts` — Pen tool: anchor points, control handles, path building +- `skia-font-manager.ts` — Font management + +### Shared Canvas Modules + +- `canvas-sync-lock.ts` — Prevents circular sync loops +- `canvas-sync-utils.ts` — `forcePageResync()` utility +- `canvas-constants.ts` — Default colors, zoom limits, stroke widths +- `canvas-node-creator.ts` — `createNodeForTool`, `isDrawingTool` +- `canvas-layout-engine.ts` — Auto-layout (delegates to `@zseven-w/pen-core`) +- `canvas-text-measure.ts` — Text width/height estimation, CJK detection +- `font-utils.ts`, `node-helpers.ts` — Re-exports from pen-core +- `insertion-indicator.ts`, `selection-context.ts`, `agent-indicator.ts`, `use-layout-indicator.ts`, `skia-engine-ref.ts` + +## Zustand Stores (`src/stores/`) + +- `canvas-store.ts` — UI/tool/selection/viewport/clipboard/interaction state, `activePageId` +- `document-store.ts` — PenDocument tree CRUD, variable CRUD, component management (all with history) +- `document-store-pages.ts` — Page actions: add, remove, rename, reorder, duplicate +- `document-tree-utils.ts` — Re-exports tree helpers and clone utilities from `@zseven-w/pen-core` +- `history-store.ts` — Undo/redo (max 300 states), batch mode +- `ai-store.ts` — Chat messages, streaming state, model selection +- `agent-settings-store.ts` — AI provider config, MCP CLI integrations, localStorage persistence +- `uikit-store.ts` — UIKit management +- `theme-preset-store.ts` — Theme preset management + +## Components (`src/components/`) + +- **`editor/`** — Editor UI: editor-layout, toolbar, boolean-toolbar, tool-button, shape-tool-dropdown, top-bar, status-bar, page-tabs, update-ready-banner +- **`panels/`** — 32 files: layer panel, property panel, fill/stroke/corner/size/text/effects/export/layout/appearance sections, AI chat panel, code panel, component browser, variables panel +- **`shared/`** — Reusable UI: ColorPicker, NumberInput, SectionHeader, ExportDialog, SaveDialog, AgentSettingsDialog, IconPickerDialog, VariablePicker, FigmaImportDialog, FontPicker, LanguageSelector +- **`icons/`** — Provider/brand logos +- **`ui/`** — shadcn/ui primitives + +## AI Services (`src/services/ai/`) + +35 files + `role-definitions/` + `design-principles/` subdirs: +- `ai-service.ts` — Main AI chat API wrapper, model negotiation, provider selection +- `ai-prompts.ts` — System prompts for design generation +- `ai-types.ts` — ChatMessage, ChatAttachment, AIDesignRequest, OrchestratorPlan +- `model-profiles.ts` — Adapts thinking mode, effort, timeouts per model tier +- `design-generator.ts` — Top-level `generateDesign`/`generateDesignModification` +- `design-parser.ts` — JSON/JSONL parsing +- `design-canvas-ops.ts` — Canvas mutation operations +- `design-node-sanitization.ts` — Node merging (re-exports `deepCloneNode` from pen-core) +- `design-validation.ts` / `design-pre-validation.ts` / `design-validation-fixes.ts` — Post-generation validation +- `icon-resolver.ts` — Auto-resolves icon names to Lucide SVG paths +- `orchestrator.ts` / `orchestrator-sub-agent.ts` / `orchestrator-prompts.ts` — Spatial decomposition orchestrator +- `context-optimizer.ts` — Chat history trimming + +## Hooks (`src/hooks/`) + +- `use-keyboard-shortcuts.ts` — Global keyboard: tools, clipboard, undo/redo, save, z-order, boolean ops +- `use-electron-menu.ts` — Electron native menu IPC listener +- `use-figma-paste.ts` — Figma clipboard paste +- `use-file-drop.ts` — File drag-and-drop +- `use-mcp-sync.ts` — MCP live canvas sync +- `use-system-fonts.ts` — System font detection + +## MCP Server (`src/mcp/`) + +- `server.ts` — MCP server entry point, tool registration (stdio + HTTP modes) +- `document-manager.ts` — Document read/write/cache; live canvas sync via Nitro API +- `tools/` — Core (open-document, batch-get, get-selection, batch-design, node-crud), Layout (snapshot-layout, find-empty-space, import-svg), Variables, Pages, Layered design (design-prompt, design-skeleton, design-content, design-refine) +- `utils/` — `id.ts`, `node-operations.ts` (re-exports `cloneNodeWithNewIds` from pen-core), `sanitize.ts`, `svg-node-parser.ts` + +## UIKit (`src/uikit/`) + +- `built-in-registry.ts` — Default built-in UIKit +- `kit-import-export.ts` — Import/export UIKits from .pen files +- `kit-utils.ts` — Extract components, find reusable nodes (re-exports `deepCloneNode` from pen-core) + +## Utilities (`src/utils/`) + +File operations: save/open .pen, export PNG/SVG, node clone (re-exports `cloneNodesWithNewIds` from pen-core), pen file normalization, SVG parser, syntax highlight, boolean operations, `app-storage.ts`, `arc-path.ts`, `theme-preset-io.ts`, `id.ts` + +## Server API (`server/`) + +- **`api/ai/`** — Nitro API (11 files): streaming chat, generation, agent connection, validation, MCP install, icon resolution, image generation/search. Supports Anthropic API key or Claude Agent SDK (local OAuth) +- **`utils/`** — Server utilities: Claude CLI resolver, OpenCode/Codex/Copilot clients, MCP server manager, sync state, server logger diff --git a/components.json b/apps/web/components.json similarity index 100% rename from components.json rename to apps/web/components.json diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 00000000..b76e1e0b --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,6 @@ +{ + "name": "@zseven-w/web", + "version": "0.5.0", + "private": true, + "type": "module" +} diff --git a/public/canvaskit/canvaskit.wasm b/apps/web/public/canvaskit/canvaskit.wasm similarity index 100% rename from public/canvaskit/canvaskit.wasm rename to apps/web/public/canvaskit/canvaskit.wasm diff --git a/public/fonts/dm-sans-400.woff2 b/apps/web/public/fonts/dm-sans-400.woff2 similarity index 100% rename from public/fonts/dm-sans-400.woff2 rename to apps/web/public/fonts/dm-sans-400.woff2 diff --git a/public/fonts/dm-sans-500.woff2 b/apps/web/public/fonts/dm-sans-500.woff2 similarity index 100% rename from public/fonts/dm-sans-500.woff2 rename to apps/web/public/fonts/dm-sans-500.woff2 diff --git a/public/fonts/dm-sans-700.woff2 b/apps/web/public/fonts/dm-sans-700.woff2 similarity index 100% rename from public/fonts/dm-sans-700.woff2 rename to apps/web/public/fonts/dm-sans-700.woff2 diff --git a/public/fonts/inter-400.woff2 b/apps/web/public/fonts/inter-400.woff2 similarity index 100% rename from public/fonts/inter-400.woff2 rename to apps/web/public/fonts/inter-400.woff2 diff --git a/public/fonts/inter-500.woff2 b/apps/web/public/fonts/inter-500.woff2 similarity index 100% rename from public/fonts/inter-500.woff2 rename to apps/web/public/fonts/inter-500.woff2 diff --git a/public/fonts/inter-600.woff2 b/apps/web/public/fonts/inter-600.woff2 similarity index 100% rename from public/fonts/inter-600.woff2 rename to apps/web/public/fonts/inter-600.woff2 diff --git a/public/fonts/inter-700.woff2 b/apps/web/public/fonts/inter-700.woff2 similarity index 100% rename from public/fonts/inter-700.woff2 rename to apps/web/public/fonts/inter-700.woff2 diff --git a/public/fonts/inter-ext-400.woff2 b/apps/web/public/fonts/inter-ext-400.woff2 similarity index 100% rename from public/fonts/inter-ext-400.woff2 rename to apps/web/public/fonts/inter-ext-400.woff2 diff --git a/public/fonts/inter-ext-500.woff2 b/apps/web/public/fonts/inter-ext-500.woff2 similarity index 100% rename from public/fonts/inter-ext-500.woff2 rename to apps/web/public/fonts/inter-ext-500.woff2 diff --git a/public/fonts/inter-ext-600.woff2 b/apps/web/public/fonts/inter-ext-600.woff2 similarity index 100% rename from public/fonts/inter-ext-600.woff2 rename to apps/web/public/fonts/inter-ext-600.woff2 diff --git a/public/fonts/inter-ext-700.woff2 b/apps/web/public/fonts/inter-ext-700.woff2 similarity index 100% rename from public/fonts/inter-ext-700.woff2 rename to apps/web/public/fonts/inter-ext-700.woff2 diff --git a/public/fonts/inter-latin.woff2 b/apps/web/public/fonts/inter-latin.woff2 similarity index 100% rename from public/fonts/inter-latin.woff2 rename to apps/web/public/fonts/inter-latin.woff2 diff --git a/public/fonts/lato-400.woff2 b/apps/web/public/fonts/lato-400.woff2 similarity index 100% rename from public/fonts/lato-400.woff2 rename to apps/web/public/fonts/lato-400.woff2 diff --git a/public/fonts/lato-700.woff2 b/apps/web/public/fonts/lato-700.woff2 similarity index 100% rename from public/fonts/lato-700.woff2 rename to apps/web/public/fonts/lato-700.woff2 diff --git a/public/fonts/montserrat-400.woff2 b/apps/web/public/fonts/montserrat-400.woff2 similarity index 100% rename from public/fonts/montserrat-400.woff2 rename to apps/web/public/fonts/montserrat-400.woff2 diff --git a/public/fonts/montserrat-500.woff2 b/apps/web/public/fonts/montserrat-500.woff2 similarity index 100% rename from public/fonts/montserrat-500.woff2 rename to apps/web/public/fonts/montserrat-500.woff2 diff --git a/public/fonts/montserrat-600.woff2 b/apps/web/public/fonts/montserrat-600.woff2 similarity index 100% rename from public/fonts/montserrat-600.woff2 rename to apps/web/public/fonts/montserrat-600.woff2 diff --git a/public/fonts/montserrat-700.woff2 b/apps/web/public/fonts/montserrat-700.woff2 similarity index 100% rename from public/fonts/montserrat-700.woff2 rename to apps/web/public/fonts/montserrat-700.woff2 diff --git a/public/fonts/noto-sans-sc-400.woff2 b/apps/web/public/fonts/noto-sans-sc-400.woff2 similarity index 100% rename from public/fonts/noto-sans-sc-400.woff2 rename to apps/web/public/fonts/noto-sans-sc-400.woff2 diff --git a/public/fonts/noto-sans-sc-700.woff2 b/apps/web/public/fonts/noto-sans-sc-700.woff2 similarity index 100% rename from public/fonts/noto-sans-sc-700.woff2 rename to apps/web/public/fonts/noto-sans-sc-700.woff2 diff --git a/public/fonts/noto-sans-sc-latin-400.woff2 b/apps/web/public/fonts/noto-sans-sc-latin-400.woff2 similarity index 100% rename from public/fonts/noto-sans-sc-latin-400.woff2 rename to apps/web/public/fonts/noto-sans-sc-latin-400.woff2 diff --git a/public/fonts/noto-sans-sc-latin-700.woff2 b/apps/web/public/fonts/noto-sans-sc-latin-700.woff2 similarity index 100% rename from public/fonts/noto-sans-sc-latin-700.woff2 rename to apps/web/public/fonts/noto-sans-sc-latin-700.woff2 diff --git a/public/fonts/nunito-400.woff2 b/apps/web/public/fonts/nunito-400.woff2 similarity index 100% rename from public/fonts/nunito-400.woff2 rename to apps/web/public/fonts/nunito-400.woff2 diff --git a/public/fonts/nunito-600.woff2 b/apps/web/public/fonts/nunito-600.woff2 similarity index 100% rename from public/fonts/nunito-600.woff2 rename to apps/web/public/fonts/nunito-600.woff2 diff --git a/public/fonts/nunito-700.woff2 b/apps/web/public/fonts/nunito-700.woff2 similarity index 100% rename from public/fonts/nunito-700.woff2 rename to apps/web/public/fonts/nunito-700.woff2 diff --git a/public/fonts/open-sans-400.woff2 b/apps/web/public/fonts/open-sans-400.woff2 similarity index 100% rename from public/fonts/open-sans-400.woff2 rename to apps/web/public/fonts/open-sans-400.woff2 diff --git a/public/fonts/open-sans-600.woff2 b/apps/web/public/fonts/open-sans-600.woff2 similarity index 100% rename from public/fonts/open-sans-600.woff2 rename to apps/web/public/fonts/open-sans-600.woff2 diff --git a/public/fonts/open-sans-700.woff2 b/apps/web/public/fonts/open-sans-700.woff2 similarity index 100% rename from public/fonts/open-sans-700.woff2 rename to apps/web/public/fonts/open-sans-700.woff2 diff --git a/public/fonts/playfair-display-400.woff2 b/apps/web/public/fonts/playfair-display-400.woff2 similarity index 100% rename from public/fonts/playfair-display-400.woff2 rename to apps/web/public/fonts/playfair-display-400.woff2 diff --git a/public/fonts/playfair-display-700.woff2 b/apps/web/public/fonts/playfair-display-700.woff2 similarity index 100% rename from public/fonts/playfair-display-700.woff2 rename to apps/web/public/fonts/playfair-display-700.woff2 diff --git a/public/fonts/poppins-400.woff2 b/apps/web/public/fonts/poppins-400.woff2 similarity index 100% rename from public/fonts/poppins-400.woff2 rename to apps/web/public/fonts/poppins-400.woff2 diff --git a/public/fonts/poppins-500.woff2 b/apps/web/public/fonts/poppins-500.woff2 similarity index 100% rename from public/fonts/poppins-500.woff2 rename to apps/web/public/fonts/poppins-500.woff2 diff --git a/public/fonts/poppins-600.woff2 b/apps/web/public/fonts/poppins-600.woff2 similarity index 100% rename from public/fonts/poppins-600.woff2 rename to apps/web/public/fonts/poppins-600.woff2 diff --git a/public/fonts/poppins-700.woff2 b/apps/web/public/fonts/poppins-700.woff2 similarity index 100% rename from public/fonts/poppins-700.woff2 rename to apps/web/public/fonts/poppins-700.woff2 diff --git a/public/fonts/raleway-400.woff2 b/apps/web/public/fonts/raleway-400.woff2 similarity index 100% rename from public/fonts/raleway-400.woff2 rename to apps/web/public/fonts/raleway-400.woff2 diff --git a/public/fonts/raleway-500.woff2 b/apps/web/public/fonts/raleway-500.woff2 similarity index 100% rename from public/fonts/raleway-500.woff2 rename to apps/web/public/fonts/raleway-500.woff2 diff --git a/public/fonts/raleway-600.woff2 b/apps/web/public/fonts/raleway-600.woff2 similarity index 100% rename from public/fonts/raleway-600.woff2 rename to apps/web/public/fonts/raleway-600.woff2 diff --git a/public/fonts/raleway-700.woff2 b/apps/web/public/fonts/raleway-700.woff2 similarity index 100% rename from public/fonts/raleway-700.woff2 rename to apps/web/public/fonts/raleway-700.woff2 diff --git a/public/fonts/roboto-400.woff2 b/apps/web/public/fonts/roboto-400.woff2 similarity index 100% rename from public/fonts/roboto-400.woff2 rename to apps/web/public/fonts/roboto-400.woff2 diff --git a/public/fonts/roboto-500.woff2 b/apps/web/public/fonts/roboto-500.woff2 similarity index 100% rename from public/fonts/roboto-500.woff2 rename to apps/web/public/fonts/roboto-500.woff2 diff --git a/public/fonts/roboto-700.woff2 b/apps/web/public/fonts/roboto-700.woff2 similarity index 100% rename from public/fonts/roboto-700.woff2 rename to apps/web/public/fonts/roboto-700.woff2 diff --git a/public/fonts/source-sans-3-400.woff2 b/apps/web/public/fonts/source-sans-3-400.woff2 similarity index 100% rename from public/fonts/source-sans-3-400.woff2 rename to apps/web/public/fonts/source-sans-3-400.woff2 diff --git a/public/fonts/source-sans-3-600.woff2 b/apps/web/public/fonts/source-sans-3-600.woff2 similarity index 100% rename from public/fonts/source-sans-3-600.woff2 rename to apps/web/public/fonts/source-sans-3-600.woff2 diff --git a/public/fonts/source-sans-3-700.woff2 b/apps/web/public/fonts/source-sans-3-700.woff2 similarity index 100% rename from public/fonts/source-sans-3-700.woff2 rename to apps/web/public/fonts/source-sans-3-700.woff2 diff --git a/electron/icon.png b/apps/web/public/icon.png similarity index 100% rename from electron/icon.png rename to apps/web/public/icon.png diff --git a/public/logo-claude.svg b/apps/web/public/logo-claude.svg similarity index 100% rename from public/logo-claude.svg rename to apps/web/public/logo-claude.svg diff --git a/public/logo-discord.svg b/apps/web/public/logo-discord.svg similarity index 100% rename from public/logo-discord.svg rename to apps/web/public/logo-discord.svg diff --git a/public/logo-openai.svg b/apps/web/public/logo-openai.svg similarity index 100% rename from public/logo-openai.svg rename to apps/web/public/logo-openai.svg diff --git a/apps/web/public/manifest.json b/apps/web/public/manifest.json new file mode 100644 index 00000000..116271b2 --- /dev/null +++ b/apps/web/public/manifest.json @@ -0,0 +1,16 @@ +{ + "short_name": "OpenPencil", + "name": "OpenPencil", + "description": "Open-source vector design tool with Design-as-Code philosophy", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#000000" +} diff --git a/public/robots.txt b/apps/web/public/robots.txt similarity index 100% rename from public/robots.txt rename to apps/web/public/robots.txt diff --git a/server/__tests__/security.test.ts b/apps/web/server/__tests__/security.test.ts similarity index 100% rename from server/__tests__/security.test.ts rename to apps/web/server/__tests__/security.test.ts diff --git a/server/api/ai/chat.ts b/apps/web/server/api/ai/chat.ts similarity index 93% rename from server/api/ai/chat.ts rename to apps/web/server/api/ai/chat.ts index 48bf2819..65b79c8d 100644 --- a/server/api/ai/chat.ts +++ b/apps/web/server/api/ai/chat.ts @@ -31,7 +31,7 @@ interface ChatBody { system: string messages: Array<{ role: 'user' | 'assistant'; content: string; attachments?: ChatAttachmentWire[] }> model?: string - provider?: 'anthropic' | 'openai' | 'opencode' | 'copilot' + provider?: 'anthropic' | 'openai' | 'opencode' | 'copilot' | 'gemini' thinkingMode?: 'adaptive' | 'disabled' | 'enabled' thinkingBudgetTokens?: number effort?: 'low' | 'medium' | 'high' | 'max' @@ -93,7 +93,7 @@ export default defineEventHandler(async (event) => { setResponseHeaders(event, { 'Content-Type': 'application/json' }) return { error: 'Missing model. Model fallback is disabled.' } } - if (body.provider !== 'anthropic' && body.provider !== 'openai' && body.provider !== 'opencode' && body.provider !== 'copilot') { + if (body.provider !== 'anthropic' && body.provider !== 'openai' && body.provider !== 'opencode' && body.provider !== 'copilot' && body.provider !== 'gemini') { setResponseHeaders(event, { 'Content-Type': 'application/json' }) return { error: 'Missing or unsupported provider. Provider fallback is disabled.' } } @@ -107,6 +107,7 @@ export default defineEventHandler(async (event) => { if (body.provider === 'anthropic') return streamViaAgentSDK(body, body.model) if (body.provider === 'opencode') return streamViaOpenCode(body, body.model) if (body.provider === 'copilot') return streamViaCopilot(body, body.model) + if (body.provider === 'gemini') return streamViaGemini(body, body.model) return streamViaCodex(body, body.model) }) @@ -720,6 +721,61 @@ function mapCopilotReasoningEffort( return effort } +/** Stream via Gemini CLI (`gemini -p -o stream-json`) — CLI handles its own auth */ +function streamViaGemini(body: ChatBody, model?: string) { + const stream = new ReadableStream({ + async start(controller) { + const encoder = new TextEncoder() + const pingTimer = setInterval(() => { + try { + controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'ping', content: '' })}\n\n`)) + } catch { /* stream already closed */ } + }, KEEPALIVE_INTERVAL_MS) + + try { + const { streamGeminiExec } = await import('../../utils/gemini-client') + + // Build prompt from messages + const lastUserMsg = [...body.messages].reverse().find((m) => m.role === 'user') + const prompt = lastUserMsg?.content ?? '' + + const { stream: geminiStream } = streamGeminiExec(prompt, { + model, + systemPrompt: body.system, + }) + + for await (const event of geminiStream) { + clearInterval(pingTimer) + if (event.type === 'text') { + const data = JSON.stringify({ type: 'text', content: event.content }) + try { + controller.enqueue(encoder.encode(`data: ${data}\n\n`)) + } catch { /* stream closed */ } + } else if (event.type === 'error') { + const data = JSON.stringify({ type: 'error', content: event.content }) + controller.enqueue(encoder.encode(`data: ${data}\n\n`)) + } + // 'done' is handled after loop + } + + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ type: 'done', content: '' })}\n\n`), + ) + } catch (error) { + const content = error instanceof Error ? error.message : 'Unknown error' + controller.enqueue( + encoder.encode(`data: ${JSON.stringify({ type: 'error', content })}\n\n`), + ) + } finally { + clearInterval(pingTimer) + controller.close() + } + }, + }) + + return new Response(stream) +} + /** Stream via GitHub Copilot SDK (@github/copilot-sdk) */ function streamViaCopilot(body: ChatBody, model?: string) { const stream = new ReadableStream({ diff --git a/server/api/ai/connect-agent.ts b/apps/web/server/api/ai/connect-agent.ts similarity index 79% rename from server/api/ai/connect-agent.ts rename to apps/web/server/api/ai/connect-agent.ts index 5941dc2b..cffe7fdb 100644 --- a/server/api/ai/connect-agent.ts +++ b/apps/web/server/api/ai/connect-agent.ts @@ -39,7 +39,7 @@ function buildExecCmd(binPath: string, args: string): string { } interface ConnectBody { - agent: 'claude-code' | 'codex-cli' | 'opencode' | 'copilot' + agent: 'claude-code' | 'codex-cli' | 'opencode' | 'copilot' | 'gemini-cli' } interface ConnectResult { @@ -82,6 +82,10 @@ export default defineEventHandler(async (event) => { return connectCopilot() } + if (body.agent === 'gemini-cli') { + return connectGeminiCli() + } + return { connected: false, models: [], error: `Unknown agent: ${body.agent}` } satisfies ConnectResult }) @@ -667,3 +671,174 @@ function friendlyOpenCodeError(raw: string): string { } return raw } + +/** Fallback model list when dynamic fetch fails */ +const FALLBACK_GEMINI_MODELS: GroupedModel[] = [ + { value: 'gemini-3-pro-preview', displayName: 'Gemini 3 Pro', description: 'Most capable', provider: 'gemini' }, + { value: 'gemini-3-flash-preview', displayName: 'Gemini 3 Flash', description: 'Fast + capable', provider: 'gemini' }, + { value: 'gemini-2.5-pro', displayName: 'Gemini 2.5 Pro', description: 'Thinking model', provider: 'gemini' }, + { value: 'gemini-2.5-flash', displayName: 'Gemini 2.5 Flash', description: 'Fast + thinking', provider: 'gemini' }, + { value: 'gemini-2.0-flash', displayName: 'Gemini 2.0 Flash', description: 'Fast model', provider: 'gemini' }, +] + +/** Fetch available models from Gemini API using local auth credentials */ +async function fetchGeminiModels(): Promise { + const { readFile } = await import('node:fs/promises') + const { homedir } = await import('node:os') + const { join } = await import('node:path') + + // Build auth header — try API key first, then OAuth token + let authUrl: (base: string) => string + let headers: Record = {} + + const envKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY + if (envKey) { + authUrl = (base) => `${base}?key=${envKey}` + } else { + // Read OAuth token + const oauthPath = join(homedir(), '.gemini', 'oauth_creds.json') + const raw = await readFile(oauthPath, 'utf-8') + const creds = JSON.parse(raw) as { access_token?: string; expiry_date?: number } + if (!creds.access_token) throw new Error('No access token') + if (creds.expiry_date && Date.now() > creds.expiry_date - 60_000) throw new Error('Token expired') + authUrl = (base) => base + headers = { Authorization: `Bearer ${creds.access_token}` } + } + + const res = await fetch(authUrl('https://generativelanguage.googleapis.com/v1beta/models'), { headers }) + if (!res.ok) throw new Error(`API ${res.status}`) + + const data = await res.json() as { + models?: Array<{ + name?: string + displayName?: string + description?: string + supportedGenerationMethods?: string[] + }> + } + + const models: GroupedModel[] = [] + const seen = new Set() + for (const m of data.models ?? []) { + // Only include models that support generateContent (text generation) + if (!m.supportedGenerationMethods?.includes('generateContent')) continue + const id = m.name?.replace('models/', '') ?? '' + if (!id || seen.has(id)) continue + // Skip embedding, AQA, and legacy models + if (/embed|aqa|^chat-bison|^text-bison|^gemini-1\.0/i.test(id)) continue + seen.add(id) + models.push({ + value: id, + displayName: m.displayName ?? id, + description: m.description?.slice(0, 60) ?? '', + provider: 'gemini' as const, + }) + } + + // Sort: gemini-3 first, then 2.5, then others + models.sort((a, b) => { + const order = (v: string) => { + if (v.includes('gemini-3')) return 0 + if (v.includes('gemini-2.5-pro')) return 1 + if (v.includes('gemini-2.5-flash')) return 2 + if (v.includes('gemini-2.0')) return 3 + return 4 + } + return order(a.value) - order(b.value) + }) + + return models +} + +/** Connect to Gemini CLI and return available models. */ +async function connectGeminiCli(): Promise { + serverLog.info('[connect-agent] connecting to Gemini CLI...') + try { + const { resolveGeminiCli } = await import('../../utils/resolve-gemini-cli') + const binPath = resolveGeminiCli() + serverLog.info(`[connect-agent] resolved gemini path: ${binPath ?? 'NOT FOUND'}`) + if (!binPath) { + return { connected: false, models: [], notInstalled: true, error: 'Gemini CLI not found' } + } + + // Verify binary responds + const { execSync } = await import('node:child_process') + const versionCmd = buildExecCmd(binPath, '--version') + try { + const ver = execSync(`${versionCmd} 2>&1`, { encoding: 'utf-8', timeout: 10000 }).trim() + serverLog.info(`[connect-agent] gemini version: ${ver}`) + } catch (err) { + serverLog.error(`[connect-agent] gemini --version failed: ${err instanceof Error ? err.message : err}`) + return { connected: false, models: [], error: 'Gemini CLI not responding' } + } + + // Dynamically fetch models, fallback to hardcoded list + let models: GroupedModel[] + try { + models = await fetchGeminiModels() + serverLog.info(`[connect-agent] gemini: fetched ${models.length} models from API`) + } catch (err) { + serverLog.info(`[connect-agent] gemini: model fetch failed (${err instanceof Error ? err.message : err}), using fallback`) + models = FALLBACK_GEMINI_MODELS + } + + const geminiInfo = await buildGeminiConnectionInfo() + const warning = models.length === 0 ? 'No models found. Try running "gemini" once to authenticate.' : undefined + if (models.length === 0) models = FALLBACK_GEMINI_MODELS + serverLog.info(`[connect-agent] gemini connected, ${models.length} models`) + return { connected: true, models, warning, ...geminiInfo } + } catch (error) { + const raw = error instanceof Error ? error.message : 'Failed to connect' + serverLog.error(`[connect-agent] gemini connection error: ${raw}`) + return { connected: false, models: [], error: friendlyGeminiError(raw) } + } +} + +/** Build Gemini CLI connection info from local config files */ +async function buildGeminiConnectionInfo(): Promise<{ connectionInfo: string; hintPath?: string }> { + const { readFile } = await import('node:fs/promises') + const { homedir } = await import('node:os') + const { join } = await import('node:path') + const hp = configPath('~/.gemini/settings.json', '%USERPROFILE%\\.gemini\\settings.json') + + // Check env for API key + const envKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY + if (envKey) { + const masked = envKey.length > 12 ? `${envKey.slice(0, 8)}...` : '***' + return { connectionInfo: `Connected via API key (${masked})`, hintPath: hp } + } + + // Check OAuth creds (Gemini CLI login) + try { + const oauthPath = join(homedir(), '.gemini', 'oauth_creds.json') + await readFile(oauthPath, 'utf-8') // Check existence + + // Try to get account email + try { + const accountsPath = join(homedir(), '.gemini', 'google_accounts.json') + const accountsRaw = await readFile(accountsPath, 'utf-8') + const accounts = JSON.parse(accountsRaw) as { active?: string } + if (accounts.active) { + return { connectionInfo: `Connected via Google (${accounts.active})`, hintPath: hp } + } + } catch { /* no accounts file */ } + + return { connectionInfo: 'Connected via Google OAuth', hintPath: hp } + } catch { /* no OAuth creds */ } + + return { connectionInfo: 'Connected via Gemini CLI', hintPath: hp } +} + +/** Map Gemini CLI errors to user-friendly messages */ +function friendlyGeminiError(raw: string): string { + if (/not found|ENOENT/i.test(raw)) { + return 'Gemini CLI not found. Install it with: npm install -g @anthropic-ai/gemini-cli' + } + if (/not authenticated|authenticate|auth|login/i.test(raw)) { + return 'Not authenticated. Run "gemini" in your terminal first to set up authentication.' + } + if (/timed?\s*out/i.test(raw)) { + return 'Connection timed out. Please try again.' + } + return raw +} diff --git a/server/api/ai/generate.ts b/apps/web/server/api/ai/generate.ts similarity index 93% rename from server/api/ai/generate.ts rename to apps/web/server/api/ai/generate.ts index 521669b3..a901bb53 100644 --- a/server/api/ai/generate.ts +++ b/apps/web/server/api/ai/generate.ts @@ -12,7 +12,7 @@ interface GenerateBody { system: string message: string model?: string - provider?: 'anthropic' | 'openai' | 'opencode' + provider?: 'anthropic' | 'openai' | 'opencode' | 'gemini' thinkingMode?: 'adaptive' | 'disabled' | 'enabled' thinkingBudgetTokens?: number effort?: 'low' | 'medium' | 'high' | 'max' @@ -48,6 +48,9 @@ export default defineEventHandler(async (event) => { if (body.provider === 'openai') { return generateViaCodex(body, body.model) } + if (body.provider === 'gemini') { + return generateViaGemini(body, body.model) + } return { error: 'Missing or unsupported provider. Provider fallback is disabled.' } }) @@ -261,3 +264,15 @@ async function generateViaOpenCode(body: GenerateBody, model?: string): Promise< releaseOpencodeServer(ocServer) } } + +/** Generate via Gemini CLI (`gemini -p -o json`) — CLI handles its own auth */ +async function generateViaGemini(body: GenerateBody, model?: string): Promise<{ text?: string; error?: string }> { + const { runGeminiExec } = await import('../../utils/gemini-client') + return runGeminiExec(body.message, { + model, + systemPrompt: body.system, + thinkingMode: body.thinkingMode, + thinkingBudgetTokens: body.thinkingBudgetTokens, + effort: body.effort, + }) +} diff --git a/server/api/ai/icon.ts b/apps/web/server/api/ai/icon.ts similarity index 100% rename from server/api/ai/icon.ts rename to apps/web/server/api/ai/icon.ts diff --git a/apps/web/server/api/ai/image-generate.ts b/apps/web/server/api/ai/image-generate.ts new file mode 100644 index 00000000..c986d712 --- /dev/null +++ b/apps/web/server/api/ai/image-generate.ts @@ -0,0 +1,311 @@ +import { defineEventHandler, readBody, setResponseHeaders, createError } from 'h3' + +interface ImageGenerateBody { + prompt: string + provider: 'openai' | 'custom' | 'gemini' | 'replicate' + model: string + apiKey: string + baseUrl?: string + width?: number + height?: number +} + +/** + * POST /api/ai/image-generate + * + * Multi-provider image generation endpoint. + * Supports OpenAI (dall-e-3/dall-e-2), Gemini (imagen), and Replicate. + * Returns { url: string } — either a remote URL or a base64 data URL. + */ +export default defineEventHandler(async (event) => { + setResponseHeaders(event, { 'Content-Type': 'application/json' }) + + const body = await readBody(event) + + if (!body?.prompt?.trim()) { + throw createError({ statusCode: 400, message: 'Missing required field: prompt' }) + } + if (!body?.provider) { + throw createError({ statusCode: 400, message: 'Missing required field: provider' }) + } + if (!body?.apiKey?.trim()) { + throw createError({ statusCode: 400, message: 'Missing required field: apiKey' }) + } + + const { prompt, provider, model, apiKey, baseUrl, width, height } = body + + if (provider === 'openai' || provider === 'custom') { + return await generateOpenAI({ prompt, model, apiKey, baseUrl, width, height }) + } + + if (provider === 'gemini') { + return await generateGemini({ prompt, model, apiKey, baseUrl, width, height }) + } + + if (provider === 'replicate') { + return await generateReplicate({ prompt, model, apiKey, baseUrl, width, height }) + } + + throw createError({ statusCode: 400, message: `Unsupported provider: ${provider}` }) +}) + +// --------------------------------------------------------------------------- +// Size mapping +// --------------------------------------------------------------------------- + +function mapToOpenAISize(w?: number, h?: number): string { + if (!w || !h) return '1024x1024' + const ratio = w / h + if (ratio > 1.3) return '1792x1024' + if (ratio < 0.77) return '1024x1792' + return '1024x1024' +} + +// --------------------------------------------------------------------------- +// OpenAI / custom OpenAI-compatible provider +// --------------------------------------------------------------------------- + +async function generateOpenAI(opts: { + prompt: string + model: string + apiKey: string + baseUrl?: string + width?: number + height?: number +}): Promise<{ url: string }> { + const { prompt, model, apiKey, baseUrl, width, height } = opts + const size = mapToOpenAISize(width, height) + const endpoint = `${baseUrl ?? 'https://api.openai.com'}/v1/images/generations` + + let res: Response + try { + res = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ model, prompt, n: 1, size, response_format: 'url' }), + }) + } catch (err) { + throw createError({ statusCode: 502, message: `OpenAI request failed: ${String(err)}` }) + } + + if (!res.ok) { + const text = await res.text().catch(() => '') + let msg = `OpenAI returned ${res.status}` + try { + const errJson = JSON.parse(text) as { error?: { message?: string } } + if (errJson.error?.message) msg = errJson.error.message + } catch { + if (text) msg += `: ${text.slice(0, 150)}` + } + throw createError({ statusCode: 502, message: msg }) + } + + const data = (await res.json()) as { data?: { url?: string }[] } + const url = data?.data?.[0]?.url + if (!url) { + throw createError({ statusCode: 502, message: 'OpenAI response missing image URL' }) + } + + return { url } +} + +// --------------------------------------------------------------------------- +// Gemini image generation +// --------------------------------------------------------------------------- + +function mapToGeminiAspectRatio(w?: number, h?: number): string | undefined { + if (!w || !h) return undefined + const ratio = w / h + if (ratio > 1.6) return '16:9' + if (ratio > 1.3) return '4:3' + if (ratio < 0.625) return '9:16' + if (ratio < 0.77) return '3:4' + return '1:1' +} + +async function generateGemini(opts: { + prompt: string + model: string + apiKey: string + baseUrl?: string + width?: number + height?: number +}): Promise<{ url: string }> { + const { prompt, model, apiKey, baseUrl, width, height } = opts + const base = baseUrl ?? 'https://generativelanguage.googleapis.com' + const endpoint = `${base}/v1beta/models/${model}:generateContent?key=${apiKey}` + + const generationConfig: Record = { responseModalities: ['TEXT', 'IMAGE'] } + const aspectRatio = mapToGeminiAspectRatio(width, height) + if (aspectRatio) { + generationConfig.imageConfig = { aspectRatio } + } + + let res: Response + try { + res = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + contents: [{ parts: [{ text: prompt }] }], + generationConfig, + }), + }) + } catch (err) { + throw createError({ statusCode: 502, message: `Gemini request failed: ${String(err)}` }) + } + + if (!res.ok) { + const text = await res.text().catch(() => '') + let msg = `Gemini returned ${res.status}` + try { + const errJson = JSON.parse(text) as { error?: { message?: string } } + if (errJson.error?.message) msg = errJson.error.message + } catch { + if (text) msg += `: ${text.slice(0, 150)}` + } + throw createError({ statusCode: 502, message: msg }) + } + + const data = (await res.json()) as { + candidates?: { + content?: { + parts?: { + inlineData?: { mimeType?: string; data?: string } + text?: string + }[] + } + }[] + } + + const parts = data?.candidates?.[0]?.content?.parts ?? [] + const imagePart = parts.find((p) => p.inlineData?.mimeType?.startsWith('image/')) + + if (!imagePart?.inlineData?.data || !imagePart.inlineData.mimeType) { + throw createError({ statusCode: 502, message: 'Gemini response missing inline image data' }) + } + + const { mimeType, data: base64data } = imagePart.inlineData + return { url: `data:${mimeType};base64,${base64data}` } +} + +// --------------------------------------------------------------------------- +// Replicate +// --------------------------------------------------------------------------- + +async function generateReplicate(opts: { + prompt: string + model: string + apiKey: string + baseUrl?: string + width?: number + height?: number +}): Promise<{ url: string }> { + const { prompt, model, apiKey, baseUrl, width, height } = opts + const base = baseUrl ?? 'https://api.replicate.com' + + // Start prediction + let createRes: Response + try { + const input: Record = { prompt } + if (width) input.width = width + if (height) input.height = height + + createRes = await fetch(`${base}/v1/predictions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ model, input }), + }) + } catch (err) { + throw createError({ statusCode: 502, message: `Replicate request failed: ${String(err)}` }) + } + + if (!createRes.ok) { + const text = await createRes.text().catch(() => '') + let msg = `Replicate returned ${createRes.status}` + try { + const errJson = JSON.parse(text) as { detail?: string } + if (errJson.detail) msg = errJson.detail + } catch { + if (text) msg += `: ${text.slice(0, 150)}` + } + throw createError({ statusCode: 502, message: msg }) + } + + const prediction = (await createRes.json()) as { id?: string; status?: string } + const predictionId = prediction?.id + if (!predictionId) { + throw createError({ statusCode: 502, message: 'Replicate response missing prediction ID' }) + } + + // Poll until succeeded or failed (max 120s, polling every 2s) + const maxAttempts = 60 + for (let attempt = 0; attempt < maxAttempts; attempt++) { + await new Promise((resolve) => setTimeout(resolve, 2000)) + + let pollRes: Response + try { + pollRes = await fetch(`${base}/v1/predictions/${predictionId}`, { + headers: { Authorization: `Bearer ${apiKey}` }, + }) + } catch (err) { + throw createError({ + statusCode: 502, + message: `Replicate poll request failed: ${String(err)}`, + }) + } + + if (!pollRes.ok) { + const text = await pollRes.text().catch(() => '') + throw createError({ + statusCode: 502, + message: `Replicate poll returned ${pollRes.status}: ${text.slice(0, 200)}`, + }) + } + + const status = (await pollRes.json()) as { + id?: string + status?: string + output?: string | string[] + error?: string + } + + if (status.status === 'succeeded') { + const output = status.output + if (Array.isArray(output)) { + const first = output[0] + if (!first) { + throw createError({ + statusCode: 502, + message: 'Replicate succeeded but output array is empty', + }) + } + return { url: first } + } + if (typeof output === 'string') { + return { url: output } + } + throw createError({ statusCode: 502, message: 'Replicate succeeded but output is missing' }) + } + + if (status.status === 'failed' || status.status === 'canceled') { + throw createError({ + statusCode: 502, + message: `Replicate prediction ${status.status}: ${status.error ?? 'unknown error'}`, + }) + } + + // Still starting/processing — keep polling + } + + throw createError({ + statusCode: 502, + message: 'Replicate prediction timed out after 120 seconds', + }) +} diff --git a/apps/web/server/api/ai/image-search.ts b/apps/web/server/api/ai/image-search.ts new file mode 100644 index 00000000..6fe5c64e --- /dev/null +++ b/apps/web/server/api/ai/image-search.ts @@ -0,0 +1,276 @@ +import { defineEventHandler, readBody, setResponseHeaders } from 'h3' +import type { ImageSearchResult, ImageSearchResponse } from '../../../src/types/image-service' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface OpenverseImageResult { + id: string + url: string + thumbnail: string + width: number + height: number + license: string + license_version: string + attribution: string +} + +interface OpenverseSearchResponse { + results: OpenverseImageResult[] +} + +interface WikimediaImageInfo { + url: string + thumburl: string + width: number + height: number + mime: string + extmetadata?: { + LicenseShortName?: { value: string } + } +} + +interface WikimediaPage { + pageid: number + title: string + imageinfo?: WikimediaImageInfo[] +} + +interface WikimediaQueryResponse { + query?: { + pages?: Record + } +} + +// --------------------------------------------------------------------------- +// OAuth token cache +// --------------------------------------------------------------------------- + +let cachedToken: string | null = null +let tokenExpiresAt = 0 + +async function getOpenverseToken(clientId: string, clientSecret: string): Promise { + const now = Date.now() + if (cachedToken && now < tokenExpiresAt) { + return cachedToken + } + + try { + const body = new URLSearchParams({ + grant_type: 'client_credentials', + client_id: clientId, + client_secret: clientSecret, + }) + const res = await fetch('https://api.openverse.org/v1/auth_tokens/token/', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body.toString(), + }) + if (!res.ok) return null + const data = (await res.json()) as { access_token: string; expires_in: number } + cachedToken = data.access_token + // Refresh 60 seconds before expiry + tokenExpiresAt = now + (data.expires_in - 60) * 1000 + return cachedToken + } catch { + return null + } +} + +// --------------------------------------------------------------------------- +// Query simplification — convert verbose AI prompts to search keywords +// --------------------------------------------------------------------------- + +const STOP_WORDS = new Set([ + 'a', 'an', 'the', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', + 'of', 'with', 'by', 'from', 'is', 'are', 'was', 'were', 'be', 'been', + 'being', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', + 'could', 'should', 'may', 'might', 'shall', 'can', 'that', 'this', + 'these', 'those', 'it', 'its', 'very', 'really', 'just', 'also', + 'about', 'above', 'after', 'before', 'between', 'into', 'through', + 'during', 'each', 'some', 'such', 'no', 'not', 'only', 'same', 'so', + 'than', 'too', 'up', 'out', 'if', 'then', 'once', 'here', 'there', + 'when', 'where', 'how', 'all', 'both', 'few', 'more', 'most', 'other', + 'any', 'as', 'while', 'using', 'showing', 'featuring', 'looking', + 'style', 'styled', 'inspired', 'based', +]) + +/** + * Simplify a verbose image generation prompt into 2-4 search keywords. + * "delicious burger with fries and fresh vegetables" → "burger fries vegetables" + * "modern office workspace with natural lighting" → "modern office workspace" + */ +export function simplifySearchQuery(prompt: string): string { + const words = prompt + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, ' ') + .split(/\s+/) + .filter((w) => w.length > 2 && !STOP_WORDS.has(w)) + + // Take up to 4 keywords + const keywords = words.slice(0, 4) + return keywords.join(' ') || prompt.slice(0, 30) +} + +// --------------------------------------------------------------------------- +// Mapping helpers (exported for testing) +// --------------------------------------------------------------------------- + +export function mapOpenverseResult(r: OpenverseImageResult): ImageSearchResult { + return { + id: r.id, + url: r.url, + thumbUrl: r.thumbnail, + width: r.width, + height: r.height, + source: 'openverse', + license: `${r.license} ${r.license_version}`.trim(), + attribution: r.attribution, + } +} + +export function mapWikimediaPages( + pages: Record, +): ImageSearchResult[] { + const results: ImageSearchResult[] = [] + for (const page of Object.values(pages)) { + const info = page.imageinfo?.[0] + if (!info) continue + results.push({ + id: String(page.pageid), + url: info.url, + thumbUrl: info.thumburl ?? info.url, + width: info.width, + height: info.height, + source: 'wikimedia', + license: info.extmetadata?.LicenseShortName?.value ?? '', + }) + } + return results +} + +// --------------------------------------------------------------------------- +// Source fetchers +// --------------------------------------------------------------------------- + +async function fetchFromOpenverse( + query: string, + count: number, + aspectRatio: string | undefined, + clientId: string | undefined, + clientSecret: string | undefined, +): Promise { + const url = new URL('https://api.openverse.org/v1/images/') + url.searchParams.set('q', query) + url.searchParams.set('page_size', String(count)) + if (aspectRatio) { + url.searchParams.set('aspect_ratio', aspectRatio) + } + + const headers: Record = {} + if (clientId && clientSecret) { + const token = await getOpenverseToken(clientId, clientSecret) + if (token) { + headers['Authorization'] = `Bearer ${token}` + } + } + + const res = await fetch(url.toString(), { headers }) + if (res.status === 429) { + // Rate limited — signal fallback + return null + } + if (!res.ok) { + return null + } + + const data = (await res.json()) as OpenverseSearchResponse + return (data.results ?? []).map(mapOpenverseResult) +} + +async function fetchFromWikimedia( + query: string, + count: number, +): Promise { + const url = new URL('https://commons.wikimedia.org/w/api.php') + url.searchParams.set('action', 'query') + url.searchParams.set('generator', 'search') + url.searchParams.set('gsrsearch', query) + url.searchParams.set('gsrnamespace', '6') + url.searchParams.set('gsrlimit', String(count)) + url.searchParams.set('prop', 'imageinfo') + url.searchParams.set('iiprop', 'url|size|mime|extmetadata') + url.searchParams.set('iiurlwidth', '800') + url.searchParams.set('format', 'json') + url.searchParams.set('origin', '*') + + const res = await fetch(url.toString()) + if (!res.ok) return [] + + const data = (await res.json()) as WikimediaQueryResponse + const pages = data.query?.pages + if (!pages) return [] + + return mapWikimediaPages(pages) +} + +// --------------------------------------------------------------------------- +// Endpoint +// --------------------------------------------------------------------------- + +/** + * POST /api/ai/image-search + * + * Searches for freely-licensed images. + * Primary source: Openverse. Falls back to Wikimedia Commons on 429. + * + * Body: { query, count?, aspectRatio?, openverseClientId?, openverseClientSecret? } + */ +export default defineEventHandler(async (event) => { + setResponseHeaders(event, { 'Content-Type': 'application/json' }) + + const body = await readBody(event) as { + query?: string + count?: number + aspectRatio?: string + openverseClientId?: string + openverseClientSecret?: string + } + + const rawQuery = body?.query?.trim() ?? '' + if (!rawQuery) { + return { error: 'Missing required field: query' } + } + + // Simplify verbose AI prompts into search-friendly keywords + const query = simplifySearchQuery(rawQuery) + + const count = Math.min(Math.max(Number(body?.count ?? 10), 1), 50) + const aspectRatio = body?.aspectRatio + const clientId = body?.openverseClientId + const clientSecret = body?.openverseClientSecret + + // Try Openverse first + const openverseResults = await fetchFromOpenverse( + query, + count, + aspectRatio, + clientId, + clientSecret, + ) + + if (openverseResults !== null) { + return { + results: openverseResults, + source: 'openverse', + } satisfies ImageSearchResponse + } + + // Openverse returned 429 or failed — fall back to Wikimedia + const wikimediaResults = await fetchFromWikimedia(query, count) + return { + results: wikimediaResults, + source: 'wikimedia', + } satisfies ImageSearchResponse +}) diff --git a/apps/web/server/api/ai/image-service-test.ts b/apps/web/server/api/ai/image-service-test.ts new file mode 100644 index 00000000..4e82118e --- /dev/null +++ b/apps/web/server/api/ai/image-service-test.ts @@ -0,0 +1,104 @@ +import { defineEventHandler, readBody } from 'h3' + +interface ImageServiceTestRequest { + service: string + apiKey?: string + model?: string + baseUrl?: string + clientId?: string + clientSecret?: string +} + +interface ImageServiceTestResponse { + valid: boolean + error?: string +} + +/** + * POST /api/ai/image-service-test + * + * Validates API keys for image generation services. + * Returns { valid: boolean, error?: string } + */ +export default defineEventHandler(async (event): Promise => { + const body = await readBody(event) + const { service, apiKey, baseUrl, clientId, clientSecret } = body ?? {} + + if (!service) { + return { valid: false, error: 'Missing required field: service' } + } + + try { + switch (service) { + case 'openverse': { + if (!clientId || !clientSecret) { + return { valid: false, error: 'Openverse requires clientId and clientSecret' } + } + const formData = new URLSearchParams() + formData.set('grant_type', 'client_credentials') + formData.set('client_id', clientId) + formData.set('client_secret', clientSecret) + const res = await fetch('https://api.openverse.org/v1/auth_tokens/token/', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: formData.toString(), + }) + if (!res.ok) { + const text = await res.text().catch(() => '') + return { valid: false, error: `Openverse auth failed (${res.status}): ${text}` } + } + return { valid: true } + } + + case 'openai': + case 'custom': { + if (!apiKey) { + return { valid: false, error: 'Missing required field: apiKey' } + } + const origin = baseUrl ?? 'https://api.openai.com' + const res = await fetch(`${origin}/v1/models`, { + headers: { Authorization: `Bearer ${apiKey}` }, + }) + if (!res.ok) { + const text = await res.text().catch(() => '') + return { valid: false, error: `Models request failed (${res.status}): ${text}` } + } + return { valid: true } + } + + case 'gemini': { + if (!apiKey) { + return { valid: false, error: 'Missing required field: apiKey' } + } + const origin = baseUrl ?? 'https://generativelanguage.googleapis.com' + const res = await fetch(`${origin}/v1beta/models?key=${encodeURIComponent(apiKey)}`) + if (!res.ok) { + const text = await res.text().catch(() => '') + return { valid: false, error: `Gemini models request failed (${res.status}): ${text}` } + } + return { valid: true } + } + + case 'replicate': { + if (!apiKey) { + return { valid: false, error: 'Missing required field: apiKey' } + } + const origin = baseUrl ?? 'https://api.replicate.com' + const res = await fetch(`${origin}/v1/models`, { + headers: { Authorization: `Bearer ${apiKey}` }, + }) + if (!res.ok) { + const text = await res.text().catch(() => '') + return { valid: false, error: `Replicate models request failed (${res.status}): ${text}` } + } + return { valid: true } + } + + default: + return { valid: false, error: `Unknown service: ${service}` } + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return { valid: false, error: message } + } +}) diff --git a/server/api/ai/install-agent.ts b/apps/web/server/api/ai/install-agent.ts similarity index 93% rename from server/api/ai/install-agent.ts rename to apps/web/server/api/ai/install-agent.ts index 27de7971..da7426b8 100644 --- a/server/api/ai/install-agent.ts +++ b/apps/web/server/api/ai/install-agent.ts @@ -2,7 +2,7 @@ import { defineEventHandler, readBody, setResponseHeaders } from 'h3' import { execSync } from 'node:child_process' interface InstallBody { - agent: 'claude-code' | 'codex-cli' | 'opencode' | 'copilot' + agent: 'claude-code' | 'codex-cli' | 'opencode' | 'copilot' | 'gemini-cli' } interface InstallResult { @@ -17,6 +17,7 @@ const BINARY_MAP: Record = { 'codex-cli': 'codex', 'opencode': 'opencode', 'copilot': 'copilot', + 'gemini-cli': 'gemini', } function checkBinary(binary: string): boolean { @@ -65,6 +66,11 @@ function getInstallInfo(agent: string): { command: string; docsUrl: string } { : 'See documentation', docsUrl: 'https://docs.github.com/copilot/how-tos/copilot-cli', } + case 'gemini-cli': + return { + command: 'npm install -g @anthropic-ai/gemini-cli', + docsUrl: 'https://github.com/anthropics/gemini-cli', + } default: return { command: '', docsUrl: '' } } @@ -117,6 +123,8 @@ async function tryAutoInstall(agent: string, binary: string): Promise { if (body.provider === 'opencode') { return await validateViaOpenCode(body, body.model) } + if (body.provider === 'gemini') { + return await validateViaGemini(body, body.model) + } return { error: 'Missing or unsupported provider. Provider fallback is disabled.' } } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error' @@ -240,3 +243,22 @@ async function validateViaOpenCode( releaseOpencodeServer(ocServer) } } + +/** Validate via Gemini CLI — saves screenshot to temp file, asks CLI to read it */ +async function validateViaGemini( + body: ValidateBody, + model?: string, +): Promise<{ text: string; skipped?: boolean; error?: string }> { + return await withTempImageFile(body.imageBase64, async (tempPath) => { + const { runGeminiExec } = await import('../../utils/gemini-client') + const prompt = `Read the image file at "${tempPath}". This is a PNG screenshot of a UI design.\n\n${body.message}\n\nOutput ONLY the JSON object, no markdown fences, no explanation.` + const result = await runGeminiExec(prompt, { + model, + systemPrompt: body.system, + }) + if (result.error) { + return { text: '', error: result.error } + } + return { text: result.text ?? '' } + }) +} diff --git a/server/api/mcp/document.get.ts b/apps/web/server/api/mcp/document.get.ts similarity index 100% rename from server/api/mcp/document.get.ts rename to apps/web/server/api/mcp/document.get.ts diff --git a/server/api/mcp/document.post.ts b/apps/web/server/api/mcp/document.post.ts similarity index 100% rename from server/api/mcp/document.post.ts rename to apps/web/server/api/mcp/document.post.ts diff --git a/server/api/mcp/events.get.ts b/apps/web/server/api/mcp/events.get.ts similarity index 100% rename from server/api/mcp/events.get.ts rename to apps/web/server/api/mcp/events.get.ts diff --git a/server/api/mcp/selection.get.ts b/apps/web/server/api/mcp/selection.get.ts similarity index 100% rename from server/api/mcp/selection.get.ts rename to apps/web/server/api/mcp/selection.get.ts diff --git a/server/api/mcp/selection.post.ts b/apps/web/server/api/mcp/selection.post.ts similarity index 100% rename from server/api/mcp/selection.post.ts rename to apps/web/server/api/mcp/selection.post.ts diff --git a/server/api/mcp/server.get.ts b/apps/web/server/api/mcp/server.get.ts similarity index 100% rename from server/api/mcp/server.get.ts rename to apps/web/server/api/mcp/server.get.ts diff --git a/server/api/mcp/server.post.ts b/apps/web/server/api/mcp/server.post.ts similarity index 100% rename from server/api/mcp/server.post.ts rename to apps/web/server/api/mcp/server.post.ts diff --git a/server/opencode/client.ts b/apps/web/server/opencode/client.ts similarity index 100% rename from server/opencode/client.ts rename to apps/web/server/opencode/client.ts diff --git a/server/opencode/gen/client.gen.ts b/apps/web/server/opencode/gen/client.gen.ts similarity index 100% rename from server/opencode/gen/client.gen.ts rename to apps/web/server/opencode/gen/client.gen.ts diff --git a/server/opencode/gen/client/client.gen.ts b/apps/web/server/opencode/gen/client/client.gen.ts similarity index 100% rename from server/opencode/gen/client/client.gen.ts rename to apps/web/server/opencode/gen/client/client.gen.ts diff --git a/server/opencode/gen/client/index.ts b/apps/web/server/opencode/gen/client/index.ts similarity index 100% rename from server/opencode/gen/client/index.ts rename to apps/web/server/opencode/gen/client/index.ts diff --git a/server/opencode/gen/client/types.gen.ts b/apps/web/server/opencode/gen/client/types.gen.ts similarity index 100% rename from server/opencode/gen/client/types.gen.ts rename to apps/web/server/opencode/gen/client/types.gen.ts diff --git a/server/opencode/gen/client/utils.gen.ts b/apps/web/server/opencode/gen/client/utils.gen.ts similarity index 100% rename from server/opencode/gen/client/utils.gen.ts rename to apps/web/server/opencode/gen/client/utils.gen.ts diff --git a/server/opencode/gen/core/auth.gen.ts b/apps/web/server/opencode/gen/core/auth.gen.ts similarity index 100% rename from server/opencode/gen/core/auth.gen.ts rename to apps/web/server/opencode/gen/core/auth.gen.ts diff --git a/server/opencode/gen/core/bodySerializer.gen.ts b/apps/web/server/opencode/gen/core/bodySerializer.gen.ts similarity index 100% rename from server/opencode/gen/core/bodySerializer.gen.ts rename to apps/web/server/opencode/gen/core/bodySerializer.gen.ts diff --git a/server/opencode/gen/core/params.gen.ts b/apps/web/server/opencode/gen/core/params.gen.ts similarity index 100% rename from server/opencode/gen/core/params.gen.ts rename to apps/web/server/opencode/gen/core/params.gen.ts diff --git a/server/opencode/gen/core/pathSerializer.gen.ts b/apps/web/server/opencode/gen/core/pathSerializer.gen.ts similarity index 100% rename from server/opencode/gen/core/pathSerializer.gen.ts rename to apps/web/server/opencode/gen/core/pathSerializer.gen.ts diff --git a/server/opencode/gen/core/queryKeySerializer.gen.ts b/apps/web/server/opencode/gen/core/queryKeySerializer.gen.ts similarity index 100% rename from server/opencode/gen/core/queryKeySerializer.gen.ts rename to apps/web/server/opencode/gen/core/queryKeySerializer.gen.ts diff --git a/server/opencode/gen/core/serverSentEvents.gen.ts b/apps/web/server/opencode/gen/core/serverSentEvents.gen.ts similarity index 100% rename from server/opencode/gen/core/serverSentEvents.gen.ts rename to apps/web/server/opencode/gen/core/serverSentEvents.gen.ts diff --git a/server/opencode/gen/core/types.gen.ts b/apps/web/server/opencode/gen/core/types.gen.ts similarity index 100% rename from server/opencode/gen/core/types.gen.ts rename to apps/web/server/opencode/gen/core/types.gen.ts diff --git a/server/opencode/gen/core/utils.gen.ts b/apps/web/server/opencode/gen/core/utils.gen.ts similarity index 100% rename from server/opencode/gen/core/utils.gen.ts rename to apps/web/server/opencode/gen/core/utils.gen.ts diff --git a/server/opencode/gen/sdk.gen.ts b/apps/web/server/opencode/gen/sdk.gen.ts similarity index 100% rename from server/opencode/gen/sdk.gen.ts rename to apps/web/server/opencode/gen/sdk.gen.ts diff --git a/server/opencode/gen/types.gen.ts b/apps/web/server/opencode/gen/types.gen.ts similarity index 100% rename from server/opencode/gen/types.gen.ts rename to apps/web/server/opencode/gen/types.gen.ts diff --git a/server/opencode/index.ts b/apps/web/server/opencode/index.ts similarity index 100% rename from server/opencode/index.ts rename to apps/web/server/opencode/index.ts diff --git a/server/opencode/server.ts b/apps/web/server/opencode/server.ts similarity index 100% rename from server/opencode/server.ts rename to apps/web/server/opencode/server.ts diff --git a/server/opencode/v2/client.ts b/apps/web/server/opencode/v2/client.ts similarity index 100% rename from server/opencode/v2/client.ts rename to apps/web/server/opencode/v2/client.ts diff --git a/server/opencode/v2/gen/client.gen.ts b/apps/web/server/opencode/v2/gen/client.gen.ts similarity index 100% rename from server/opencode/v2/gen/client.gen.ts rename to apps/web/server/opencode/v2/gen/client.gen.ts diff --git a/server/opencode/v2/gen/client/client.gen.ts b/apps/web/server/opencode/v2/gen/client/client.gen.ts similarity index 100% rename from server/opencode/v2/gen/client/client.gen.ts rename to apps/web/server/opencode/v2/gen/client/client.gen.ts diff --git a/server/opencode/v2/gen/client/index.ts b/apps/web/server/opencode/v2/gen/client/index.ts similarity index 100% rename from server/opencode/v2/gen/client/index.ts rename to apps/web/server/opencode/v2/gen/client/index.ts diff --git a/server/opencode/v2/gen/client/types.gen.ts b/apps/web/server/opencode/v2/gen/client/types.gen.ts similarity index 100% rename from server/opencode/v2/gen/client/types.gen.ts rename to apps/web/server/opencode/v2/gen/client/types.gen.ts diff --git a/server/opencode/v2/gen/client/utils.gen.ts b/apps/web/server/opencode/v2/gen/client/utils.gen.ts similarity index 100% rename from server/opencode/v2/gen/client/utils.gen.ts rename to apps/web/server/opencode/v2/gen/client/utils.gen.ts diff --git a/server/opencode/v2/gen/core/auth.gen.ts b/apps/web/server/opencode/v2/gen/core/auth.gen.ts similarity index 100% rename from server/opencode/v2/gen/core/auth.gen.ts rename to apps/web/server/opencode/v2/gen/core/auth.gen.ts diff --git a/server/opencode/v2/gen/core/bodySerializer.gen.ts b/apps/web/server/opencode/v2/gen/core/bodySerializer.gen.ts similarity index 100% rename from server/opencode/v2/gen/core/bodySerializer.gen.ts rename to apps/web/server/opencode/v2/gen/core/bodySerializer.gen.ts diff --git a/server/opencode/v2/gen/core/params.gen.ts b/apps/web/server/opencode/v2/gen/core/params.gen.ts similarity index 100% rename from server/opencode/v2/gen/core/params.gen.ts rename to apps/web/server/opencode/v2/gen/core/params.gen.ts diff --git a/server/opencode/v2/gen/core/pathSerializer.gen.ts b/apps/web/server/opencode/v2/gen/core/pathSerializer.gen.ts similarity index 100% rename from server/opencode/v2/gen/core/pathSerializer.gen.ts rename to apps/web/server/opencode/v2/gen/core/pathSerializer.gen.ts diff --git a/server/opencode/v2/gen/core/queryKeySerializer.gen.ts b/apps/web/server/opencode/v2/gen/core/queryKeySerializer.gen.ts similarity index 100% rename from server/opencode/v2/gen/core/queryKeySerializer.gen.ts rename to apps/web/server/opencode/v2/gen/core/queryKeySerializer.gen.ts diff --git a/server/opencode/v2/gen/core/serverSentEvents.gen.ts b/apps/web/server/opencode/v2/gen/core/serverSentEvents.gen.ts similarity index 100% rename from server/opencode/v2/gen/core/serverSentEvents.gen.ts rename to apps/web/server/opencode/v2/gen/core/serverSentEvents.gen.ts diff --git a/server/opencode/v2/gen/core/types.gen.ts b/apps/web/server/opencode/v2/gen/core/types.gen.ts similarity index 100% rename from server/opencode/v2/gen/core/types.gen.ts rename to apps/web/server/opencode/v2/gen/core/types.gen.ts diff --git a/server/opencode/v2/gen/core/utils.gen.ts b/apps/web/server/opencode/v2/gen/core/utils.gen.ts similarity index 100% rename from server/opencode/v2/gen/core/utils.gen.ts rename to apps/web/server/opencode/v2/gen/core/utils.gen.ts diff --git a/server/opencode/v2/gen/sdk.gen.ts b/apps/web/server/opencode/v2/gen/sdk.gen.ts similarity index 100% rename from server/opencode/v2/gen/sdk.gen.ts rename to apps/web/server/opencode/v2/gen/sdk.gen.ts diff --git a/server/opencode/v2/gen/types.gen.ts b/apps/web/server/opencode/v2/gen/types.gen.ts similarity index 100% rename from server/opencode/v2/gen/types.gen.ts rename to apps/web/server/opencode/v2/gen/types.gen.ts diff --git a/server/opencode/v2/index.ts b/apps/web/server/opencode/v2/index.ts similarity index 100% rename from server/opencode/v2/index.ts rename to apps/web/server/opencode/v2/index.ts diff --git a/server/opencode/v2/server.ts b/apps/web/server/opencode/v2/server.ts similarity index 100% rename from server/opencode/v2/server.ts rename to apps/web/server/opencode/v2/server.ts diff --git a/server/plugins/port-file.ts b/apps/web/server/plugins/port-file.ts similarity index 100% rename from server/plugins/port-file.ts rename to apps/web/server/plugins/port-file.ts diff --git a/server/utils/codex-client.ts b/apps/web/server/utils/codex-client.ts similarity index 100% rename from server/utils/codex-client.ts rename to apps/web/server/utils/codex-client.ts diff --git a/server/utils/copilot-client.ts b/apps/web/server/utils/copilot-client.ts similarity index 100% rename from server/utils/copilot-client.ts rename to apps/web/server/utils/copilot-client.ts diff --git a/apps/web/server/utils/gemini-client.ts b/apps/web/server/utils/gemini-client.ts new file mode 100644 index 00000000..4c9538d2 --- /dev/null +++ b/apps/web/server/utils/gemini-client.ts @@ -0,0 +1,393 @@ +import { spawn } from 'node:child_process' +import { resolveGeminiCli } from './resolve-gemini-cli' + +type ThinkingMode = 'adaptive' | 'disabled' | 'enabled' +type ThinkingEffort = 'low' | 'medium' | 'high' | 'max' + +export interface GeminiExecOptions { + model?: string + systemPrompt?: string + thinkingMode?: ThinkingMode + thinkingBudgetTokens?: number + effort?: ThinkingEffort + timeoutMs?: number +} + +interface GeminiCliResult { + text?: string + error?: string +} + +const DEFAULT_GEMINI_TIMEOUT_MS = 15 * 60 * 1000 + +/** + * Allowlist-based env filter for Gemini CLI subprocess. + * Passes through safe system vars and Google/Gemini-specific prefixes. + */ +const GEMINI_ENV_ALLOWLIST = new Set([ + 'PATH', 'HOME', 'TERM', 'LANG', 'SHELL', 'TMPDIR', + // Windows-essential + 'SYSTEMROOT', 'COMSPEC', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA', + 'PATHEXT', 'SYSTEMDRIVE', 'TEMP', 'TMP', 'HOMEDRIVE', 'HOMEPATH', +]) + +function filterGeminiEnv( + env: Record, +): Record { + const result: Record = {} + for (const [k, v] of Object.entries(env)) { + if ( + GEMINI_ENV_ALLOWLIST.has(k) + || k.startsWith('GOOGLE_') + || k.startsWith('GEMINI_') + || k.startsWith('GCLOUD_') + ) { + result[k] = v + } + } + return result +} + +/** + * Run Gemini CLI in non-interactive mode with JSON output. + * Passes prompt via stdin to avoid command-line length limits. + * The CLI handles its own authentication (OAuth or API key). + */ +export async function runGeminiExec( + userPrompt: string, + options: GeminiExecOptions = {}, +): Promise { + const binPath = resolveGeminiCli() + if (!binPath) { + return { error: 'Gemini CLI not found. Install it first.' } + } + + const prompt = buildPrompt(options.systemPrompt, userPrompt) + + const args = [ + '-o', 'json', + '--approval-mode', 'plan', + ] + + if (options.model) { + args.push('-m', options.model) + } + + // Use -p with a minimal marker; full prompt piped via stdin. + // Gemini CLI appends -p value after stdin content. + args.push('-p', ' ') + + try { + const result = await executeGeminiCommand( + binPath, + args, + options.timeoutMs ?? DEFAULT_GEMINI_TIMEOUT_MS, + prompt, + ) + return result + } catch (error) { + return { error: error instanceof Error ? error.message : 'Gemini execution failed' } + } +} + +/** + * Stream Gemini CLI output in real-time using `stream-json` format. + * Passes prompt via stdin. Yields text deltas as they arrive. + */ +export function streamGeminiExec( + userPrompt: string, + options: GeminiExecOptions = {}, +): { + stream: AsyncGenerator<{ type: 'text' | 'error' | 'done'; content: string }> + kill: () => void +} { + const binPath = resolveGeminiCli() + if (!binPath) { + return { + stream: (async function* () { + yield { type: 'error' as const, content: 'Gemini CLI not found.' } + })(), + kill: () => {}, + } + } + + const prompt = buildPrompt(options.systemPrompt, userPrompt) + + const args = [ + '-o', 'stream-json', + '--approval-mode', 'plan', + ] + + if (options.model) { + args.push('-m', options.model) + } + + // Use -p with minimal marker; full prompt piped via stdin. + args.push('-p', ' ') + + const child = spawn(binPath, args, { + env: filterGeminiEnv(process.env as Record), + stdio: ['pipe', 'pipe', 'pipe'], + ...(process.platform === 'win32' && { shell: true }), + }) + + // Pipe prompt via stdin + if (child.stdin) { + child.stdin.write(prompt) + child.stdin.end() + } + + const timeoutMs = options.timeoutMs ?? DEFAULT_GEMINI_TIMEOUT_MS + const timer = setTimeout(() => { + child.kill('SIGTERM') + }, timeoutMs) + + async function* generateStream(): AsyncGenerator<{ type: 'text' | 'error' | 'done'; content: string }> { + let buffer = '' + + child.stderr?.on('data', () => { /* discard stderr */ }) + + try { + for await (const chunk of child.stdout!) { + buffer += chunk.toString('utf-8') + let idx = buffer.indexOf('\n') + while (idx >= 0) { + const line = buffer.slice(0, idx).trim() + buffer = buffer.slice(idx + 1) + if (line) { + const event = parseStreamJsonLine(line) + if (event) yield event + } + idx = buffer.indexOf('\n') + } + } + + // Flush remaining buffer + const tail = buffer.trim() + if (tail) { + const event = parseStreamJsonLine(tail) + if (event) yield event + } + + yield { type: 'done', content: '' } + } catch (error) { + const msg = error instanceof Error ? error.message : 'Stream error' + yield { type: 'error', content: msg } + } finally { + clearTimeout(timer) + } + } + + return { + stream: generateStream(), + kill: () => { + clearTimeout(timer) + child.kill('SIGTERM') + }, + } +} + +function buildPrompt(systemPrompt: string | undefined, userPrompt: string): string { + const userText = userPrompt.trim() + if (!systemPrompt?.trim()) return userText + + return [ + 'You are a design generation assistant. Follow the guidelines below to produce the requested output.', + '', + '--- GUIDELINES ---', + systemPrompt.trim(), + '', + '--- TASK ---', + userText, + ].join('\n') +} + +async function executeGeminiCommand( + binPath: string, + args: string[], + timeoutMs: number, + stdinText?: string, +): Promise { + return await new Promise((resolve, reject) => { + const child = spawn(binPath, args, { + env: filterGeminiEnv(process.env as Record), + stdio: [stdinText ? 'pipe' : 'ignore', 'pipe', 'pipe'], + ...(process.platform === 'win32' && { shell: true }), + }) + + // Pipe prompt via stdin + if (stdinText && child.stdin) { + child.stdin.write(stdinText) + child.stdin.end() + } + + let stdoutBuffer = '' + let stderrBuffer = '' + + const timer = setTimeout(() => { + child.kill('SIGTERM') + reject(new Error(`Gemini request timed out after ${Math.round(timeoutMs / 1000)}s.`)) + }, timeoutMs) + + child.stdout!.on('data', (chunk: Buffer) => { + stdoutBuffer += chunk.toString('utf-8') + }) + + child.stderr!.on('data', (chunk: Buffer) => { + stderrBuffer += chunk.toString('utf-8') + }) + + child.on('error', (err) => { + clearTimeout(timer) + reject(err) + }) + + child.on('close', (code) => { + clearTimeout(timer) + + // Parse JSON output — Gemini CLI always outputs a JSON object at the end of stdout. + // Error text / stack traces may appear before it. + const parsed = parseGeminiJsonOutput(stdoutBuffer) + + if (parsed) { + if (parsed.response) { + resolve({ text: parsed.response }) + return + } + if (parsed.errorMessage) { + resolve({ error: friendlyGeminiApiError(parsed.errorMessage) }) + return + } + } + + if (code !== 0) { + // Extract meaningful error from stderr or stdout + const errorMsg = extractGeminiError(stdoutBuffer, stderrBuffer) + resolve({ error: errorMsg || `Gemini exited with code ${code ?? 'unknown'}.` }) + return + } + + const raw = stdoutBuffer.trim() + resolve(raw ? { text: raw } : { error: 'Gemini returned no output.' }) + }) + }) +} + +/** + * Parse Gemini CLI JSON output. + * The CLI may print error text before the final JSON object. + * We search from the END of stdout for the last valid JSON block. + */ +function parseGeminiJsonOutput(raw: string): { response?: string; errorMessage?: string } | null { + const trimmed = raw.trim() + if (!trimmed) return null + + // Search backwards for the last top-level JSON object (starts with `{` at line beginning) + const lines = trimmed.split('\n') + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i].trim() + if (!line.startsWith('{')) continue + + // Try to parse from this line to the end + const candidate = lines.slice(i).join('\n').trim() + try { + const data = JSON.parse(candidate) as Record + // Must have session_id to be a valid Gemini CLI response + if (!data.session_id && !data.response && !data.error) continue + + const response = typeof data.response === 'string' ? data.response : undefined + + // error can be a string or an object { type, message, code } + let errorMessage: string | undefined + if (data.error) { + if (typeof data.error === 'string') { + errorMessage = data.error + } else if (typeof data.error === 'object' && data.error !== null) { + const errObj = data.error as Record + errorMessage = typeof errObj.message === 'string' ? errObj.message : JSON.stringify(data.error) + } + } + + return { response, errorMessage } + } catch { /* not valid JSON from this point */ } + } + + return null +} + +/** Extract a human-readable error from Gemini CLI stdout/stderr */ +function extractGeminiError(stdout: string, stderr: string): string | null { + // Look for quota errors + const quotaMatch = stdout.match(/quota will reset after (\S+)/i) + || stderr.match(/quota will reset after (\S+)/i) + if (quotaMatch) { + return `Gemini quota exhausted. Resets after ${quotaMatch[1]}.` + } + + // Look for TerminalQuotaError or other named errors + const namedError = stdout.match(/(Terminal\w+Error|ApiError|AuthError):\s*(.+)/m) + if (namedError) { + return namedError[2].trim() + } + + // Stderr fallback + const stderrTrimmed = stderr.trim() + if (stderrTrimmed) return stderrTrimmed + + return null +} + +/** Map raw Gemini API errors to user-friendly messages */ +function friendlyGeminiApiError(raw: string): string { + if (/quota|exhausted|429|capacity/i.test(raw)) { + const resetMatch = raw.match(/reset after (\S+)/i) + return resetMatch + ? `Gemini quota exhausted. Resets after ${resetMatch[1]}.` + : 'Gemini quota exhausted. Please wait and try again.' + } + if (/401|unauthenticated|auth/i.test(raw)) { + return 'Gemini auth expired. Run "gemini" in your terminal to re-authenticate.' + } + if (/\[object Object\]/.test(raw)) { + return 'Gemini API error. Check your quota or try a different model.' + } + return raw +} + +function parseStreamJsonLine( + line: string, +): { type: 'text' | 'error' | 'done'; content: string } | null { + // Skip non-JSON lines (e.g. "Loaded cached credentials.") + if (!line.startsWith('{')) return null + + let parsed: Record + try { + parsed = JSON.parse(line) as Record + } catch { + return null + } + + const type = typeof parsed.type === 'string' ? parsed.type : '' + + if (type === 'message' && parsed.role === 'assistant') { + const content = typeof parsed.content === 'string' ? parsed.content : '' + if (content) return { type: 'text', content } + } + + if (type === 'result') { + // Check for error in result event + if (parsed.status === 'error' && parsed.error) { + const errObj = parsed.error as Record + const msg = typeof errObj.message === 'string' ? errObj.message : 'Unknown error' + return { type: 'error', content: friendlyGeminiApiError(msg) } + } + return null + } + + if (type === 'error') { + const content = typeof parsed.message === 'string' ? parsed.message : 'Unknown error' + return { type: 'error', content: friendlyGeminiApiError(content) } + } + + return null +} diff --git a/server/utils/mcp-server-manager.ts b/apps/web/server/utils/mcp-server-manager.ts similarity index 100% rename from server/utils/mcp-server-manager.ts rename to apps/web/server/utils/mcp-server-manager.ts diff --git a/server/utils/mcp-sync-state.ts b/apps/web/server/utils/mcp-sync-state.ts similarity index 100% rename from server/utils/mcp-sync-state.ts rename to apps/web/server/utils/mcp-sync-state.ts diff --git a/server/utils/opencode-client.ts b/apps/web/server/utils/opencode-client.ts similarity index 100% rename from server/utils/opencode-client.ts rename to apps/web/server/utils/opencode-client.ts diff --git a/server/utils/resolve-claude-agent-env.ts b/apps/web/server/utils/resolve-claude-agent-env.ts similarity index 84% rename from server/utils/resolve-claude-agent-env.ts rename to apps/web/server/utils/resolve-claude-agent-env.ts index 8c86fdc4..a1adcecf 100644 --- a/server/utils/resolve-claude-agent-env.ts +++ b/apps/web/server/utils/resolve-claude-agent-env.ts @@ -122,17 +122,36 @@ export function getClaudeAgentDebugFilePath(): string | undefined { /** * Custom spawnClaudeCodeProcess for Windows. * On Windows, npm-installed CLIs are .cmd/.ps1 scripts that can't be spawned - * directly without a shell. Uses PowerShell to avoid cmd.exe's 8191-char limit. + * directly without a shell. + * + * - `.cmd` files: use `cmd.exe /c` (PowerShell can't run .cmd directly) + * - `.ps1` files: use `powershell.exe` + * - `.exe` files or others: use `cmd.exe /c` as safe default */ export function buildSpawnClaudeCodeProcess() { if (process.platform !== 'win32') return undefined return (options: { command: string; args: string[]; cwd?: string; env: Record; signal: AbortSignal }) => { - return spawn(options.command, options.args, { + const cmd = options.command + const isPowerShell = cmd.endsWith('.ps1') + + if (isPowerShell) { + // For .ps1 scripts, invoke via PowerShell + const psArgs = ['-ExecutionPolicy', 'Bypass', '-File', cmd, ...options.args] + return spawn('powershell.exe', psArgs, { + cwd: options.cwd, + env: options.env as NodeJS.ProcessEnv, + signal: options.signal, + stdio: ['pipe', 'pipe', 'pipe'], + }) + } + + // For .cmd, .exe, or extensionless binaries, use cmd.exe /c + return spawn(cmd, options.args, { cwd: options.cwd, env: options.env as NodeJS.ProcessEnv, signal: options.signal, stdio: ['pipe', 'pipe', 'pipe'], - shell: 'powershell.exe', + shell: true, }) } } diff --git a/server/utils/resolve-claude-cli.ts b/apps/web/server/utils/resolve-claude-cli.ts similarity index 100% rename from server/utils/resolve-claude-cli.ts rename to apps/web/server/utils/resolve-claude-cli.ts diff --git a/apps/web/server/utils/resolve-gemini-cli.ts b/apps/web/server/utils/resolve-gemini-cli.ts new file mode 100644 index 00000000..44e3c286 --- /dev/null +++ b/apps/web/server/utils/resolve-gemini-cli.ts @@ -0,0 +1,93 @@ +import { execSync } from 'node:child_process' +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { homedir } from 'node:os' +import { serverLog } from './server-logger' + +const isWindows = process.platform === 'win32' + +/** Windows npm global installs may create .cmd or .ps1 wrappers — try both */ +function winNpmCandidates(dir: string, name: string): string[] { + return [join(dir, `${name}.cmd`), join(dir, `${name}.ps1`)] +} + +/** On Windows, `where` may return an extensionless shell script — prefer .cmd/.ps1 */ +function resolveWinExtension(binPath: string): string { + if (!isWindows) return binPath + if (/\.(cmd|ps1|exe)$/i.test(binPath)) return binPath + for (const ext of ['.cmd', '.ps1']) { + if (existsSync(binPath + ext)) return binPath + ext + } + return binPath +} + +/** Resolve the Gemini CLI binary path across macOS, Linux, and Windows. */ +export function resolveGeminiCli(): string | undefined { + serverLog.info(`[resolve-gemini] platform=${process.platform}, isWindows=${isWindows}`) + + // 1. Try PATH lookup + try { + const cmd = isWindows ? 'where gemini 2>nul' : 'which gemini 2>/dev/null' + serverLog.info(`[resolve-gemini] PATH lookup: ${cmd}`) + const result = execSync(cmd, { encoding: 'utf-8', timeout: 5000 }).trim() + // `where` on Windows may return multiple lines + const path = result.split(/\r?\n/)[0]?.trim() + serverLog.info(`[resolve-gemini] PATH result: "${path}" (exists=${path ? existsSync(path) : false})`) + if (path && existsSync(path)) return resolveWinExtension(path) + } catch (err) { + serverLog.info(`[resolve-gemini] PATH lookup failed: ${err instanceof Error ? err.message : err}`) + } + + // 2. Try `npm prefix -g` (Windows uses npm.cmd; Unix uses npm) + try { + const npmCmd = isWindows ? 'npm.cmd prefix -g' : 'npm prefix -g' + serverLog.info(`[resolve-gemini] npm prefix lookup: ${npmCmd}`) + const prefix = execSync(npmCmd, { encoding: 'utf-8', timeout: 5000 }).trim() + serverLog.info(`[resolve-gemini] npm global prefix: "${prefix}"`) + if (prefix) { + if (isWindows) { + for (const bin of winNpmCandidates(prefix, 'gemini')) { + serverLog.info(`[resolve-gemini] npm global bin: "${bin}" (exists=${existsSync(bin)})`) + if (existsSync(bin)) return bin + } + } else { + const bin = join(prefix, 'bin', 'gemini') + serverLog.info(`[resolve-gemini] npm global bin: "${bin}" (exists=${existsSync(bin)})`) + if (existsSync(bin)) return bin + } + } + } catch (err) { + serverLog.info(`[resolve-gemini] npm prefix -g failed: ${err instanceof Error ? err.message : err}`) + } + + // 3. Common install locations + const home = homedir() + const candidates = isWindows + ? [ + // npm global (.cmd + .ps1) + ...winNpmCandidates(join(process.env.APPDATA || '', 'npm'), 'gemini'), + // nvm-windows / fnm + ...winNpmCandidates(join(process.env.NVM_SYMLINK || ''), 'gemini'), + ...winNpmCandidates(join(process.env.FNM_MULTISHELL_PATH || ''), 'gemini'), + // winget / native + join(process.env.LOCALAPPDATA || '', 'Microsoft', 'WinGet', 'Links', 'gemini.exe'), + ] + : [ + // npm global + '/usr/local/bin/gemini', + // Homebrew (macOS) + '/opt/homebrew/bin/gemini', + // User-local + join(home, '.local', 'bin', 'gemini'), + join(home, '.npm-global', 'bin', 'gemini'), + ] + + for (const c of candidates) { + const exists = c ? existsSync(c) : false + serverLog.info(`[resolve-gemini] candidate: "${c}" (exists=${exists})`) + if (c && exists) return c + } + + serverLog.warn('[resolve-gemini] no gemini binary found') + return undefined +} diff --git a/server/utils/server-logger.ts b/apps/web/server/utils/server-logger.ts similarity index 100% rename from server/utils/server-logger.ts rename to apps/web/server/utils/server-logger.ts diff --git a/src/canvas/agent-indicator.ts b/apps/web/src/canvas/agent-indicator.ts similarity index 100% rename from src/canvas/agent-indicator.ts rename to apps/web/src/canvas/agent-indicator.ts diff --git a/apps/web/src/canvas/canvas-constants.ts b/apps/web/src/canvas/canvas-constants.ts new file mode 100644 index 00000000..0a264953 --- /dev/null +++ b/apps/web/src/canvas/canvas-constants.ts @@ -0,0 +1,49 @@ +// Re-export pure constants from @zseven-w/pen-core +export { + MIN_ZOOM, + MAX_ZOOM, + ZOOM_STEP, + SNAP_THRESHOLD, + DEFAULT_FILL, + DEFAULT_STROKE, + DEFAULT_STROKE_WIDTH, + CANVAS_BACKGROUND_LIGHT, + CANVAS_BACKGROUND_DARK, + SELECTION_BLUE, + COMPONENT_COLOR, + INSTANCE_COLOR, + HOVER_BLUE, + HOVER_LINE_WIDTH, + HOVER_DASH, + INDICATOR_BLUE, + INDICATOR_LINE_WIDTH, + INDICATOR_DASH, + INDICATOR_ENDPOINT_RADIUS, + FRAME_LABEL_FONT_SIZE, + FRAME_LABEL_OFFSET_Y, + FRAME_LABEL_COLOR, + PEN_ANCHOR_FILL, + PEN_ANCHOR_RADIUS, + PEN_ANCHOR_FIRST_RADIUS, + PEN_HANDLE_DOT_RADIUS, + PEN_HANDLE_LINE_STROKE, + PEN_RUBBER_BAND_STROKE, + PEN_RUBBER_BAND_DASH, + PEN_CLOSE_HIT_THRESHOLD, + DIMENSION_LABEL_OFFSET_Y, + DEFAULT_FRAME_FILL, + DEFAULT_TEXT_FILL, + GUIDE_COLOR, + GUIDE_LINE_WIDTH, + GUIDE_DASH, +} from '@zseven-w/pen-core' + +import { CANVAS_BACKGROUND_LIGHT, CANVAS_BACKGROUND_DARK } from '@zseven-w/pen-core' + +// Browser-only function — not in pen-core +export function getCanvasBackground(): string { + if (typeof document === 'undefined') return CANVAS_BACKGROUND_DARK + return document.documentElement.classList.contains('light') + ? CANVAS_BACKGROUND_LIGHT + : CANVAS_BACKGROUND_DARK +} diff --git a/apps/web/src/canvas/canvas-layout-engine.ts b/apps/web/src/canvas/canvas-layout-engine.ts new file mode 100644 index 00000000..1c45ec9c --- /dev/null +++ b/apps/web/src/canvas/canvas-layout-engine.ts @@ -0,0 +1,15 @@ +// Re-export from @zseven-w/pen-core — the canonical source +export { + type Padding, + resolvePadding, + isNodeVisible, + setRootChildrenProvider, + getRootFillWidthFallback, + inferLayout, + fitContentWidth, + fitContentHeight, + getNodeWidth, + getNodeHeight, + computeLayoutPositions, + estimateLineWidth, +} from '@zseven-w/pen-core' diff --git a/src/canvas/canvas-node-creator.ts b/apps/web/src/canvas/canvas-node-creator.ts similarity index 100% rename from src/canvas/canvas-node-creator.ts rename to apps/web/src/canvas/canvas-node-creator.ts diff --git a/apps/web/src/canvas/canvas-sync-lock.ts b/apps/web/src/canvas/canvas-sync-lock.ts new file mode 100644 index 00000000..f584423f --- /dev/null +++ b/apps/web/src/canvas/canvas-sync-lock.ts @@ -0,0 +1,2 @@ +// Re-export from @zseven-w/pen-core — the canonical source +export { isFabricSyncLocked, setFabricSyncLock } from '@zseven-w/pen-core' diff --git a/src/canvas/canvas-sync-utils.ts b/apps/web/src/canvas/canvas-sync-utils.ts similarity index 100% rename from src/canvas/canvas-sync-utils.ts rename to apps/web/src/canvas/canvas-sync-utils.ts diff --git a/apps/web/src/canvas/canvas-text-measure.ts b/apps/web/src/canvas/canvas-text-measure.ts new file mode 100644 index 00000000..f1de4fc0 --- /dev/null +++ b/apps/web/src/canvas/canvas-text-measure.ts @@ -0,0 +1,110 @@ +// Re-export pure functions from @zseven-w/pen-core +export { + parseSizing, + defaultLineHeight, + isCjkCodePoint, + hasCjkText, + estimateGlyphWidth, + estimateLineWidth, + widthSafetyFactor, + estimateTextWidth, + estimateTextWidthPrecise, + resolveTextContent, + countExplicitTextLines, + getTextOpticalCenterYOffset, + estimateTextHeight, + setWrappedLineCounter, +} from '@zseven-w/pen-core' + +import { + isCjkCodePoint, + estimateLineWidth, + widthSafetyFactor, + setWrappedLineCounter, +} from '@zseven-w/pen-core' +import { cssFontFamily } from './font-utils' + +// --------------------------------------------------------------------------- +// Canvas 2D measurement context (lazy singleton, browser-only) +// Wire up the browser-based wrapped line counter at module load time. +// --------------------------------------------------------------------------- + +let _textMeasureCtx: CanvasRenderingContext2D | null = null +function getTextMeasureCtx(): CanvasRenderingContext2D | null { + if (typeof document === 'undefined') return null + if (!_textMeasureCtx) { + const c = document.createElement('canvas') + _textMeasureCtx = c.getContext('2d') + } + return _textMeasureCtx +} + +function countWrappedLinesCanvas2D( + rawLines: string[], + wrapWidth: number, + fontSize: number, + fontWeight: string | number | undefined, + fontFamily: string, + letterSpacing: number, +): number { + const ctx = getTextMeasureCtx() + if (!ctx) { + return rawLines.reduce((sum, line) => { + const lineWidth = estimateLineWidth(line, fontSize, letterSpacing, fontWeight) * widthSafetyFactor(line) + return sum + Math.max(1, Math.ceil(lineWidth / wrapWidth)) + }, 0) + } + + const fw = typeof fontWeight === 'number' ? String(fontWeight) : (fontWeight ?? '400') + ctx.font = `${fw} ${fontSize}px ${cssFontFamily(fontFamily)}` + + let total = 0 + for (const rawLine of rawLines) { + if (!rawLine) { total += 1; continue } + if (ctx.measureText(rawLine).width <= wrapWidth) { total += 1; continue } + let lineCount = 0 + let current = '' + let i = 0 + while (i < rawLine.length) { + const ch = rawLine[i] + if (isCjkCodePoint(ch.codePointAt(0) ?? 0)) { + const test = current + ch + if (ctx.measureText(test).width > wrapWidth && current) { + lineCount++ + current = ch + } else { + current = test + } + i++ + } else if (ch === ' ') { + const test = current + ch + if (ctx.measureText(test).width > wrapWidth && current) { + lineCount++ + current = '' + } else { + current = test + } + i++ + } else { + let word = '' + while (i < rawLine.length && rawLine[i] !== ' ' && !isCjkCodePoint(rawLine[i].codePointAt(0) ?? 0)) { + word += rawLine[i] + i++ + } + const test = current + word + if (ctx.measureText(test).width > wrapWidth && current) { + lineCount++ + current = word + } else { + current = test + } + } + } + if (current) lineCount++ + total += Math.max(1, lineCount) + } + return total +} + +// Register the Canvas 2D counter so pen-core's estimateTextHeight uses it +setWrappedLineCounter(countWrappedLinesCanvas2D) diff --git a/apps/web/src/canvas/font-utils.ts b/apps/web/src/canvas/font-utils.ts new file mode 100644 index 00000000..d9f6c33f --- /dev/null +++ b/apps/web/src/canvas/font-utils.ts @@ -0,0 +1,2 @@ +// Re-export from @zseven-w/pen-core — the canonical source +export { cssFontFamily } from '@zseven-w/pen-core' diff --git a/src/canvas/insertion-indicator.ts b/apps/web/src/canvas/insertion-indicator.ts similarity index 100% rename from src/canvas/insertion-indicator.ts rename to apps/web/src/canvas/insertion-indicator.ts diff --git a/apps/web/src/canvas/node-helpers.ts b/apps/web/src/canvas/node-helpers.ts new file mode 100644 index 00000000..f6f80e24 --- /dev/null +++ b/apps/web/src/canvas/node-helpers.ts @@ -0,0 +1,2 @@ +// Re-export from @zseven-w/pen-core — the canonical source +export { isBadgeOverlayNode } from '@zseven-w/pen-core' diff --git a/src/canvas/selection-context.ts b/apps/web/src/canvas/selection-context.ts similarity index 100% rename from src/canvas/selection-context.ts rename to apps/web/src/canvas/selection-context.ts diff --git a/src/canvas/skia-engine-ref.ts b/apps/web/src/canvas/skia-engine-ref.ts similarity index 100% rename from src/canvas/skia-engine-ref.ts rename to apps/web/src/canvas/skia-engine-ref.ts diff --git a/apps/web/src/canvas/skia/skia-canvas.tsx b/apps/web/src/canvas/skia/skia-canvas.tsx new file mode 100644 index 00000000..79dd1655 --- /dev/null +++ b/apps/web/src/canvas/skia/skia-canvas.tsx @@ -0,0 +1,199 @@ +import { useRef, useEffect, useState } from 'react' +import { loadCanvasKit } from './skia-init' +import { SkiaEngine } from './skia-engine' +import { useCanvasStore } from '@/stores/canvas-store' +import { useDocumentStore } from '@/stores/document-store' +import { setSkiaEngineRef } from '../skia-engine-ref' +import type { PenNode } from '@/types/pen' +import { SkiaInteractionManager, type TextEditState } from './skia-interaction' + +export default function SkiaCanvas() { + const canvasRef = useRef(null) + const containerRef = useRef(null) + const engineRef = useRef(null) + const [error, setError] = useState(null) + const [editingText, setEditingText] = useState(null) + + // Initialize CanvasKit + engine + useEffect(() => { + let disposed = false + + async function init() { + try { + const ck = await loadCanvasKit() + if (disposed) return + + const canvasEl = canvasRef.current + if (!canvasEl) return + + const engine = new SkiaEngine(ck) + engine.init(canvasEl) + engineRef.current = engine + setSkiaEngineRef(engine) + + // Initial sync + engine.syncFromDocument() + requestAnimationFrame(() => engine.zoomToFitContent()) + + } catch (err) { + console.error('SkiaCanvas init failed:', err) + setError(String(err)) + } + } + + init() + + return () => { + disposed = true + setSkiaEngineRef(null) + engineRef.current?.dispose() + engineRef.current = null + } + }, []) + + // Resize observer + useEffect(() => { + const container = containerRef.current + if (!container) return + const observer = new ResizeObserver((entries) => { + const engine = engineRef.current + if (!engine) return + for (const entry of entries) { + const { width, height } = entry.contentRect + engine.resize(width, height) + } + }) + observer.observe(container) + return () => observer.disconnect() + }, []) + + // Document sync: re-render when document changes + useEffect(() => { + const unsub = useDocumentStore.subscribe(() => { + engineRef.current?.syncFromDocument() + }) + return unsub + }, []) + + // Page sync: re-render when active page changes + useEffect(() => { + let prevPageId = useCanvasStore.getState().activePageId + const unsub = useCanvasStore.subscribe((state) => { + if (state.activePageId !== prevPageId) { + prevPageId = state.activePageId + engineRef.current?.syncFromDocument() + } + }) + return unsub + }, []) + + // Selection sync: re-render when selection changes + useEffect(() => { + let prevIds = useCanvasStore.getState().selection.selectedIds + const unsub = useCanvasStore.subscribe((state) => { + if (state.selection.selectedIds !== prevIds) { + prevIds = state.selection.selectedIds + engineRef.current?.markDirty() + } + }) + return unsub + }, []) + + // Wheel: zoom + pan + useEffect(() => { + const canvasEl = canvasRef.current + if (!canvasEl) return + + const handleWheel = (e: WheelEvent) => { + e.preventDefault() + e.stopPropagation() + const engine = engineRef.current + if (!engine) return + + if (e.ctrlKey || e.metaKey) { + let delta = -e.deltaY + if (e.deltaMode === 1) delta *= 40 + const factor = Math.pow(1.005, delta) + const newZoom = engine.zoom * factor + engine.zoomToPoint(e.clientX, e.clientY, newZoom) + } else { + let dx = -e.deltaX + let dy = -e.deltaY + if (e.deltaMode === 1) { dx *= 40; dy *= 40 } + engine.pan(dx, dy) + } + } + + canvasEl.addEventListener('wheel', handleWheel, { passive: false }) + return () => canvasEl.removeEventListener('wheel', handleWheel) + }, []) + + // Mouse/keyboard interactions (select, move, resize, draw, hover, etc.) + useEffect(() => { + const canvasEl = canvasRef.current + if (!canvasEl) return + + const manager = new SkiaInteractionManager(engineRef, canvasEl, setEditingText) + return manager.attach() + }, []) + + return ( +
+ + {editingText && ( +