/** Infinite canvas workspace, rebuilt after the node-graph model of * basketikun/infinite-canvas: free nodes (image/text), drag-to-connect edges, * marquee + multi selection, context menus, minimap, undo/redo and a floating * generation composer. Selecting a node pops the composer: the prompt is typed * there (or supplied by connected text nodes), every upstream image node joins * as a reference, and results land as new image nodes on the right. */ import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type CSSProperties, type PointerEvent as ReactPointerEvent, type ReactNode } from 'react' import { createPortal } from 'react-dom' import { Bold, BookOpen, ChevronDown, Copy, Download, Eraser, FileText as FileTextIcon, FolderX, Hand, Image as ImageIcon, Layers, Map as MapIcon, Maximize, Maximize2, MousePointer2, Palette, Pencil, Plus, Redo2, Scissors, SendHorizonal, Sparkles, Star, SquareDashedMousePointer, Trash2, Type, Undo2, Upload, Wand2, Wallpaper, X, } from 'lucide-react' import type { CanvasAnnotation, CanvasAssetRef, CanvasConnection, CanvasDocument, CanvasFileKind, CanvasLayerInfo, CanvasLayerPlanItem, CanvasNode, CanvasRect, CanvasSkillConfigApplyResult, CanvasSkillConfigField, CanvasSkillConfigSaveResult, CanvasSkillConfigStep, CanvasSkillConfigView, CanvasSkillDescriptor, CanvasSkillLibrary, CanvasSkillOutput, CanvasSkillRunRequest, CanvasSkillTask, CanvasSketchStroke, GenerateRequest, GenerationTask, HistoryEntry } from '../protocol.ts' import type { ImageGenApi } from './api.ts' import { errorMessage, tt } from './helpers.ts' import { autoRemoveBackground, canvasToDataUrl, compositeAnnotatedResult, containRect, cropRaster, drawAnnotation, loadRaster, rectBetween, transparencyRatio } from './image-ops.ts' import { PosterEditor } from './PosterEditor.tsx' import { TemplateLibrary, TemplateLibraryButton } from './TemplateLibrary.tsx' import { ModelPicker, resolveModelChoice, type ModelPickerValue } from './ModelPicker.tsx' import type { ImageModelGroup } from './settings-scope.ts' import { CanvasFileBody, CanvasFileOverlay, fileKindLabel, fileKindOfAsset, fileSizeLabel } from './CanvasFilePreview.tsx' import { DotFieldBackground, DotGridBackground, FaultyTerminalBackground, FloatingLinesBackground, FlowBackground, GalaxyBackground, LiquidEtherBackground, ShapeGridBackground, SilkBackground, WavesBackground } from './CanvasBackgrounds.tsx' import { Select } from './Select.tsx' import css from './canvas-workspace.module.css' type CanvasTool = 'select' | 'pan' type BackgroundMode = CanvasDocument['background'] const MIN_SCALE = 0.05 const MAX_SCALE = 5 const GRID_SIZE = 48 const IMAGE_NODE_SIZE = { width: 240, height: 240 } const TEXT_NODE_SIZE = { width: 280, height: 150 } /** File nodes mirror the host runner's footprint so produced files land in a * column that matches hand-made ones. */ const FILE_NODE_SIZE = { width: 300, height: 170 } const CONFIG_NODE_SIZE = { width: 320, height: 190 } const LEGACY_CONFIG_NODE_SIZE = { width: 240, height: 96 } /** Sketch boards keep a fixed frame (header + square-ish board + two tool rows) * so the normalized strokes always map onto the same rect. */ const SKETCH_NODE_SIZE = { width: 300, height: 396 } const SKETCH_EXPORT_WIDTH = 1024 const SKETCH_COLORS = ['#1f2328', '#e03131', '#1971c2', '#2f9e44', '#f59f00', '#9c36b5', '#0ca678', '#f06595'] const SKETCH_WIDTHS = [3, 6, 12] const SKETCH_WIDTH_DOTS = [5, 8, 12] const SKETCH_ERASER_RADIUS = 16 const HISTORY_LIMIT = 60 const WORLD_PAD = 12000 /** Footprint of the prompt card the 标注 tool drops next to a boxed image. */ const ANNOTATION_TEXT_SIZE = { width: 268, height: 132 } /** Horizontal gap used when new nodes are laid out beside their source. */ const NODE_GAP = 90 /** Smallest normalized box the annotation tool accepts (area fraction). */ const MIN_ANNOTATION_AREA = 0.0004 /** Text colors offered by the text-node palette (first entry = theme default). */ const TEXT_COLORS = ['', '#1f2328', '#ffffff', '#e03131', '#1971c2', '#2f9e44', '#f59f00', '#9c36b5'] /** Layer-split output column: node width and vertical gap (the gap also keeps * a hover toolbar, which hangs below each image node, off its neighbour). */ const LAYER_COLUMN_WIDTH = 280 const LAYER_COLUMN_GAP = 48 /** Smallest footprint a node can be resized to. */ const MIN_NODE_WIDTH = 96 const MIN_NODE_HEIGHT = 64 /** Upper bound on the nodes one menu action may cover (mirrors the host cap). */ const MAX_BATCH_SKILL_NODES = 12 interface CanvasWorkspaceProps { api: ImageGenApi modelGroups: ImageModelGroup[] defaultChannelId?: string connected: boolean /** Channels whose credentials are currently usable. */ connectedChannelIds?: ReadonlySet /** Whether the chat model used by canvas light actions is configured. */ promptReady?: boolean history: HistoryEntry[] gallery: HistoryEntry[] tasks: GenerationTask[] importRequest?: { source: 'history' | 'gallery'; entryId: string; imageIndex: number } onImportRequestHandled?: () => void onOpenSettings?: (kind?: 'generation' | 'enhancement') => void } type Point = { x: number; y: number } interface NodeDragState { pointerId: number startX: number startY: number moved: boolean snapshot: string | null origins: Map } interface PanState { startX: number startY: number viewportX: number viewportY: number hasMoved: boolean startedOnBackground: boolean } interface MarqueeState { start: Point current: Point additive: boolean initialIds: string[] } interface ConnectState { nodeId: string handleType: 'source' | 'target' mouse: Point targetId: string | null /** False for a plain click on the handle (opens the add-node menu), true * once the pointer travels far enough that this is a drag-to-connect. */ moved: boolean startClient: Point } /** Which corner the pointer grabbed. */ type ResizeCorner = 'nw' | 'ne' | 'sw' | 'se' interface ResizeState { nodeId: string corner: ResizeCorner startX: number startY: number width: number height: number x: number y: number ratio: number | null } type ContextMenuState = | { type: 'canvas'; screen: Point; world: Point } | { type: 'node'; screen: Point; nodeId: string } | { type: 'connection'; screen: Point; connectionId: string } type ProjectSummary = Awaited>[number] function newId(prefix: string): string { const random = globalThis.crypto?.randomUUID?.() return `${prefix}-${random ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`}` } function imageDataUrl(image: { b64: string; mime: string }): string { return `data:${image.mime};base64,${image.b64}` } function readImageSize(src: string): Promise<{ width: number; height: number }> { return new Promise((resolve, reject) => { const image = new Image() image.onload = () => resolve({ width: image.naturalWidth || 1, height: image.naturalHeight || 1 }) image.onerror = () => reject(new Error('无法读取图片尺寸')) image.src = src }) } async function assetToDataUrl(asset: CanvasAssetRef): Promise { if (asset.url.startsWith('data:')) return asset.url const response = await fetch(asset.url) if (!response.ok) throw new Error('读取画布图片失败') const blob = await response.blob() return await new Promise((resolve, reject) => { const reader = new FileReader() reader.onload = () => resolve(String(reader.result)) reader.onerror = () => reject(new Error('读取画布图片失败')) reader.readAsDataURL(blob) }) } function clampScale(scale: number): number { return Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale)) } function sizeForAsset(asset: CanvasAssetRef): { width: number; height: number } { const ratio = asset.width > 0 && asset.height > 0 ? asset.width / asset.height : 1 if (ratio >= 1) return { width: IMAGE_NODE_SIZE.width, height: Math.max(160, Math.round(IMAGE_NODE_SIZE.width / ratio)) } return { width: Math.max(200, Math.round(IMAGE_NODE_SIZE.height * ratio)), height: IMAGE_NODE_SIZE.height } } /** Node footprint for a generation size ratio such as '1:1' or '16:9'. */ function nodeSizeFromRatio(size: string | undefined, spec: { width: number; height: number }): { width: number; height: number } { const match = /^(\d+):(\d+)$/.exec(size ?? '') if (match === null) return { ...spec } const width = Number(match[1]) const height = Number(match[2]) if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return { ...spec } const ratio = width / height return ratio >= 1 ? { width: spec.width, height: Math.max(160, Math.round(spec.width / ratio)) } : { width: Math.max(200, Math.round(spec.height * ratio)), height: spec.height } } function nodesBounds(nodes: CanvasNode[]): { minX: number; minY: number; maxX: number; maxY: number } { return nodes.reduce((acc, node) => ({ minX: Math.min(acc.minX, node.x), minY: Math.min(acc.minY, node.y), maxX: Math.max(acc.maxX, node.x + node.width), maxY: Math.max(acc.maxY, node.y + node.height), }), { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }) } /** Enlarge config nodes still stored at the pre-expansion default so the * roomier layout applies to existing canvases too. */ function normalizeConfigNodeSizes(document: CanvasDocument): CanvasDocument { const nodes = document.nodes.map(node => node.type === 'config' && node.width === LEGACY_CONFIG_NODE_SIZE.width && node.height === LEGACY_CONFIG_NODE_SIZE.height ? { ...node, width: CONFIG_NODE_SIZE.width, height: CONFIG_NODE_SIZE.height } : node) return nodes === document.nodes ? document : { ...document, nodes } } function summaryOf(document: CanvasDocument, favorite = false): ProjectSummary { return { id: document.id, title: document.title, revision: document.revision, nodeCount: document.nodes.length, createdAt: document.createdAt, updatedAt: document.updatedAt, ...favorite ? { favorite: true } : {}, } } function nodeMetadata(node: CanvasNode): NonNullable { return node.metadata ?? {} } /** The single asset carried by an image or file node. */ function assetOf(node: CanvasNode): CanvasAssetRef | undefined { if (node.type === 'image') return nodeMetadata(node).asset return node.type === 'file' ? nodeMetadata(node).asset : undefined } function usableAsset(node: CanvasNode): CanvasAssetRef | undefined { const asset = assetOf(node) return asset !== undefined && asset.url !== '' ? asset : undefined } function bezierPath(from: Point, to: Point): string { const distance = Math.abs(to.x - from.x) const bend = Math.max(distance * 0.5, 50) return `M ${from.x} ${from.y} C ${from.x + bend} ${from.y}, ${to.x - bend} ${to.y}, ${to.x} ${to.y}` } function nodeAnchor(node: CanvasNode, side: 'left' | 'right'): Point { return { x: side === 'right' ? node.x + node.width : node.x, y: node.y + node.height / 2 } } function isSketchNode(node: CanvasNode): boolean { return node.type === 'image' && nodeMetadata(node).sketch !== undefined } /** Letterboxed rect of an `object-fit: contain` image inside its container. */ function clamp01(value: number): number { return Math.min(1, Math.max(0, value)) } /** Annotation boxes whose prompt card still exists. */ function liveAnnotations(node: CanvasNode, nodes: CanvasNode[]): CanvasAnnotation[] { const annotations = nodeMetadata(node).annotations ?? [] return annotations.filter(annotation => annotation.nodeId === undefined || nodes.some(item => item.id === annotation.nodeId)) } /** Drop annotation boxes whose prompt card was deleted, so image nodes do not * accumulate invisible orphan boxes. */ function pruneAnnotations(nodes: CanvasNode[], removed: Set): CanvasNode[] { if (removed.size === 0) return nodes return nodes.map(node => { const annotations = nodeMetadata(node).annotations if (annotations === undefined || annotations.length === 0) return node const kept = annotations.filter(annotation => annotation.nodeId === undefined || !removed.has(annotation.nodeId)) return kept.length === annotations.length ? node : { ...node, metadata: { ...nodeMetadata(node), annotations: kept } } }) } /** Expand a deletion to the prompt cards attached to the deleted image nodes, * then remove everything and prune the surviving annotation lists. */ function removeNodesAndCards(nodes: CanvasNode[], requested: Set): { nodes: CanvasNode[]; removed: Set } { const removed = new Set(requested) for (const node of nodes) { if (node.type !== 'image' || !removed.has(node.id)) continue for (const annotation of nodeMetadata(node).annotations ?? []) { if (annotation.nodeId !== undefined) removed.add(annotation.nodeId) } } return { nodes: pruneAnnotations(nodes.filter(node => !removed.has(node.id)), removed), removed } } /** Prompt cards attached to an image node's annotation boxes, in box order. */ function attachedCards(image: CanvasNode, nodes: CanvasNode[]): CanvasNode[] { if (image.type !== 'image') return [] return liveAnnotations(image, nodes).flatMap(annotation => { if (annotation.nodeId === undefined) return [] const card = nodes.find(node => node.id === annotation.nodeId) return card === undefined ? [] : [card] }) } /** Is this node one of the prompt cards the 标注 tool hangs off an image? */ function isAnnotationCard(node: CanvasNode): boolean { return node.type === 'text' && nodeMetadata(node).annotation !== undefined } /** World-space anchor of an annotation box (its right edge, vertically centred). */ function annotationAnchor(image: CanvasNode, rect: CanvasRect): Point { return { x: image.x + (rect.x + rect.width) * image.width, y: image.y + (rect.y + rect.height / 2) * image.height, } } /** Clone a set of nodes plus the prompt cards attached to any cloned image * node, remapping every attachment link to the copies. */ function cloneNodesWithCards(source: CanvasNode[], allNodes: CanvasNode[], dx: number, dy: number): { clones: CanvasNode[]; idMap: Map } { const idMap = new Map() const clones: CanvasNode[] = [] const push = (node: CanvasNode, metadata: CanvasNode['metadata']): void => { const id = newId('node') idMap.set(node.id, id) clones.push({ ...node, id, x: Math.round(node.x + dx), y: Math.round(node.y + dy), metadata: metadata ?? { ...nodeMetadata(node) } }) } for (const node of source) push(node, undefined) for (const node of source) { if (node.type !== 'image') continue for (const card of attachedCards(node, allNodes)) { if (idMap.has(card.id)) continue const metadata = nodeMetadata(card) const annotation = metadata.annotation push(card, { ...metadata, ...annotation === undefined ? {} : { annotation: { ...annotation, sourceNodeId: idMap.get(annotation.sourceNodeId) ?? annotation.sourceNodeId } }, }) } } for (const clone of clones) { const annotations = clone.type === 'image' ? nodeMetadata(clone).annotations : undefined if (annotations === undefined) continue clone.metadata = { ...nodeMetadata(clone), annotations: annotations.map(annotation => annotation.nodeId !== undefined && idMap.has(annotation.nodeId) ? { ...annotation, nodeId: idMap.get(annotation.nodeId) } : annotation), } } return { clones, idMap } } /** New geometry for a corner drag, keeping the opposite corner pinned. */ function resizedRect(resize: ResizeState, clientDx: number, clientDy: number, scale: number): { x: number; y: number; width: number; height: number } { const dx = clientDx / scale const dy = clientDy / scale const east = resize.corner === 'ne' || resize.corner === 'se' const south = resize.corner === 'sw' || resize.corner === 'se' let width = Math.max(MIN_NODE_WIDTH, resize.width + (east ? dx : -dx)) let height = Math.max(MIN_NODE_HEIGHT, resize.height + (south ? dy : -dy)) if (resize.ratio !== null) { // Aspect-locked (images): follow whichever axis the pointer moved further, // so dragging sideways or downwards both scale the picture. const fromWidth = width const fromHeight = height / resize.ratio width = Math.abs(fromWidth - resize.width) >= Math.abs(fromHeight - resize.width) ? fromWidth : fromHeight width = Math.max(MIN_NODE_WIDTH, width) height = Math.max(MIN_NODE_HEIGHT, width * resize.ratio) } return { x: Math.round(east ? resize.x : resize.x + (resize.width - width)), y: Math.round(south ? resize.y : resize.y + (resize.height - height)), width: Math.round(width), height: Math.round(height), } } /** Localized layer-kind badge label. */ function layerKindLabel(kind: CanvasLayerInfo['kind']): string { return kind === 'background' ? tt('canvas.layerKindBackground') : kind === 'object' ? tt('canvas.layerKindObject') : tt('canvas.layerKindText') } /** Left edge of a free vertical band beside `source`, so a freshly created * column of nodes never lands on top of existing ones. */ function freeColumnX(source: CanvasNode, nodes: CanvasNode[], columnHeight: number, columnWidth: number): number { let x = source.x + source.width + NODE_GAP for (let guard = 0; guard < 24; guard += 1) { const clash = nodes.filter(node => node.id !== source.id && x < node.x + node.width + 24 && x + columnWidth > node.x - 24 && source.y < node.y + node.height + 24 && source.y + columnHeight > node.y - 24) if (clash.length === 0) break x = Math.max(...clash.map(node => node.x + node.width)) + 48 } return Math.round(x) } /** Inline style for a text node's content, honoring size/color/weight. */ function textStyleOf(node: CanvasNode): { fontSize: number; color?: string; fontWeight?: number } { const metadata = nodeMetadata(node) const fontSize = typeof metadata.fontSize === 'number' && metadata.fontSize > 0 ? metadata.fontSize : 13 return { fontSize: Math.min(96, Math.max(8, fontSize)), ...metadata.color === undefined || metadata.color === '' ? {} : { color: metadata.color }, ...metadata.bold === true ? { fontWeight: 700 } : {}, } } /** Draw one normalized stroke onto a 2d context already sized to the board. */ function drawSketchStroke(ctx: CanvasRenderingContext2D, stroke: CanvasSketchStroke, boardWidth: number, boardHeight: number): void { ctx.strokeStyle = stroke.color ctx.fillStyle = stroke.color ctx.lineWidth = stroke.width ctx.lineCap = 'round' ctx.lineJoin = 'round' const points = stroke.points if (points.length === 0) return if (points.length === 1) { const only = points[0]! ctx.beginPath() ctx.arc(only.x * boardWidth, only.y * boardHeight, stroke.width / 2, 0, Math.PI * 2) ctx.fill() return } ctx.beginPath() points.forEach((point, index) => { const x = point.x * boardWidth const y = point.y * boardHeight if (index === 0) ctx.moveTo(x, y) else ctx.lineTo(x, y) }) ctx.stroke() } /** Flatten strokes onto an opaque white PNG sized for gpt-image edits (the * models treat the sketch as a plain reference image). */ function rasterizeSketch(strokes: CanvasSketchStroke[], boardWidth: number, boardHeight: number): { dataUrl: string; width: number; height: number } { const scale = SKETCH_EXPORT_WIDTH / Math.max(1, boardWidth) const canvas = globalThis.document.createElement('canvas') canvas.width = Math.max(1, Math.round(boardWidth * scale)) canvas.height = Math.max(1, Math.round(boardHeight * scale)) const ctx = canvas.getContext('2d') if (ctx === null) throw new Error('canvas 2d context unavailable') ctx.fillStyle = '#ffffff' ctx.fillRect(0, 0, canvas.width, canvas.height) ctx.scale(scale, scale) for (const stroke of strokes) drawSketchStroke(ctx, stroke, boardWidth, boardHeight) return { dataUrl: canvas.toDataURL('image/png'), width: canvas.width, height: canvas.height } } type ToolbarIconName = 'new' | 'select' | 'pan' | 'image' | 'text' | 'file' | 'sketch' | 'eraser' | 'trash' | 'undo' | 'redo' | 'fit' | 'minimap' | 'background' | 'template' | 'download' | 'duplicate' | 'sparkle' | 'send' | 'close' | 'deleteProject' | 'annotate' | 'removeBg' | 'layers' | 'bold' | 'color' | 'skill' | 'upload' | 'expand' | 'favorite' | 'favoriteFilled' /** Lucide icons (stroke matches the DSH line style); one shared component so * every dock/toolbar icon comes from the same well-drawn set. */ function ToolbarIcon({ name, size = 16 }: { name: ToolbarIconName; size?: number }): React.JSX.Element { const common = { size, strokeWidth: 1.6, 'aria-hidden': true as const } switch (name) { case 'new': return case 'select': return case 'pan': return case 'image': return case 'text': return case 'file': return case 'sketch': return case 'eraser': return case 'trash': return case 'undo': return case 'redo': return case 'fit': return case 'minimap': return case 'background': return case 'template': return case 'download': return case 'duplicate': return case 'sparkle': return case 'send': return case 'close': return case 'deleteProject': return case 'annotate': return case 'removeBg': return case 'layers': return case 'bold': return case 'color': return case 'skill': return case 'upload': return case 'expand': return case 'favorite': return case 'favoriteFilled': return } } /** Extensions the file picker refuses before a byte leaves the browser; the * host refuses them again, so this is only a fast, friendly guard. */ const BLOCKED_UPLOAD_EXTENSIONS = new Set([ 'exe', 'com', 'scr', 'pif', 'cpl', 'msi', 'msp', 'dll', 'sys', 'bat', 'cmd', 'ps1', 'psm1', 'vbs', 'vbe', 'js', 'mjs', 'cjs', 'jse', 'wsf', 'wsh', 'hta', 'jar', 'apk', 'app', 'sh', 'bash', 'command', 'reg', 'lnk', 'url', 'html', 'htm', 'xhtml', 'svg', ]) /** Cap mirroring MAX_CANVAS_FILE_BYTES on the host. */ const MAX_UPLOAD_BYTES = 50 * 1024 * 1024 /** * Modal picker for the canvas skill catalog. Built-in actions come first, then * every host skill; incompatible entries stay visible but disabled so the menu * explains itself instead of hiding. */ function SkillPicker(props: { anchor: Point nodeType: CanvasNode['type'] /** Title of the node the menu was opened on (shown in the header). */ targetTitle: string catalog: { skills: CanvasSkillDescriptor[]; reason?: string } | null loading: boolean /** Number of nodes the run will cover (1 = the acting node). */ batchCount: number /** Host-skill names the registry exposes; undefined = registry unavailable. */ installed?: ReadonlySet onPick: (skill: CanvasSkillDescriptor) => void /** Open the library installer prefilled with one entry's upstream. */ onInstall: (skill: CanvasSkillDescriptor) => void /** Open the skill library focused on one skill's configuration form. */ onConfigure: (skill: CanvasSkillDescriptor) => void onClose: () => void }): React.JSX.Element { const [query, setQuery] = useState('') const term = query.trim().toLowerCase() const skills = (props.catalog?.skills ?? []).filter(skill => term === '' || skill.name.toLowerCase().includes(term) || skill.description.toLowerCase().includes(term)) const compatible = (skill: CanvasSkillDescriptor): boolean => props.nodeType === 'config' ? false : skill.accepts.includes(props.nodeType) // The skills this node can actually run come first inside every group, so a // mis-click never lands on a greyed-out row sitting on top of the menu. const byFit = (a: CanvasSkillDescriptor, b: CanvasSkillDescriptor): number => Number(compatible(b)) - Number(compatible(a)) const builtin = skills.filter(skill => skill.origin === 'builtin').sort(byFit) const external = skills.filter(skill => skill.origin === 'external').sort(byFit) const missing = (skill: CanvasSkillDescriptor): boolean => props.installed !== undefined && skill.skillName !== undefined && !props.installed.has(skill.skillName) const row = (skill: CanvasSkillDescriptor): React.JSX.Element => { const needsInstall = missing(skill) const needsConfig = !needsInstall && (skill.configMissing?.length ?? 0) > 0 const title = needsInstall ? tt('canvas.skills.missingHint', { name: skill.skillName ?? skill.name }) : compatible(skill) ? skill.description : tt('canvas.skills.incompatible') return
{needsInstall ? : needsConfig ? : {skill.tier === 'heavy' ? tt('canvas.skills.tierHeavy') : tt('canvas.skills.tierLight')} }
} const style: CSSProperties = { left: Math.max(8, Math.min(props.anchor.x, (globalThis.innerWidth || 1024) - 380)), top: Math.max(8, Math.min(props.anchor.y, (globalThis.innerHeight || 768) - 420)), } return createPortal(<>
{tt('canvas.skills.menuTitle')} {props.targetTitle !== '' ? {tt('canvas.skills.runOn', { name: props.targetTitle })} : null}
setQuery(event.target.value)} /> {props.batchCount > 1 ?

{tt('canvas.skills.batchNote', { count: props.batchCount })}

: null}
{props.loading && props.catalog === null ?

{tt('canvas.skills.loading')}

: null} {!props.loading && skills.length === 0 ?

{tt('canvas.skills.empty')}

: null} {builtin.length > 0 ?

{tt('canvas.skills.groupBuiltin')}

: null} {builtin.map(row)} {external.length > 0 ?

{tt('canvas.skills.groupExternal')}

: props.catalog !== null && builtin.length > 0 ?

{tt('canvas.skills.emptyExternal')}

: null} {external.map(row)}
{props.catalog?.reason !== undefined ?

{props.catalog.reason}

: null}
, globalThis.document.body) } /** * The per-skill configuration form (see `docs/skill-config.md`). * * One skill declares its fields and how they take effect; this renders them and * offers two actions — save, or save-and-apply (which runs the declaration's own * commands / file writes host-side). Secret fields never round-trip: they show * only whether a value is stored, and the save request carries just the ones the * user touched. */ function SkillConfigForm(props: { name: string config: CanvasSkillConfigView busy: boolean onSave: (name: string, values: Array<{ id: string; value: string }>) => Promise onApply: (name: string) => Promise }): React.JSX.Element { const initial = (field: CanvasSkillConfigField): string => { if (field.type === 'secret') return '' return props.config.values.find(value => value.id === field.id)?.value ?? field.default ?? '' } const [draft, setDraft] = useState>(() => Object.fromEntries(props.config.fields.map(field => [field.id, initial(field)]))) const [touched, setTouched] = useState>(() => new Set()) const [phase, setPhase] = useState<'idle' | 'saving' | 'applying'>('idle') const [steps, setSteps] = useState(null) const [message, setMessage] = useState(null) // A reload replaces the entry with a fresh object: re-seed the drafts so the // form never shows stale values after a save or an external edit. const signature = props.config.fields.map(field => `${field.id}:${initial(field)}`).join('|') useEffect(() => { setDraft(Object.fromEntries(props.config.fields.map(field => [field.id, initial(field)]))) setTouched(new Set()) // eslint-disable-next-line react-hooks/exhaustive-deps }, [signature]) const payload = (): Array<{ id: string; value: string }> => props.config.fields.flatMap(field => { if (field.type === 'secret') return touched.has(field.id) ? [{ id: field.id, value: draft[field.id] ?? '' }] : [] return [{ id: field.id, value: draft[field.id] ?? '' }] }) const save = async (apply: boolean): Promise => { setPhase(apply ? 'applying' : 'saving') setMessage(null) setSteps(null) try { const saved = await props.onSave(props.name, payload()) if (!saved.ok) { setMessage(saved.message ?? tt('canvas.skills.configSaveFailed')) return } if (!apply) { setMessage(tt('canvas.skills.configSaved')) return } const applied = await props.onApply(props.name) setSteps(applied.steps) setMessage(applied.ok ? tt('canvas.skills.configApplied') : applied.message ?? tt('canvas.skills.configApplyFailed')) } catch (caught) { setMessage(caught instanceof Error ? caught.message : String(caught)) } finally { setPhase('idle') } } return
{props.config.note === undefined ? null :

{props.config.note}

} {props.config.issue === undefined ? null :

{props.config.issue}

}

{props.config.source === 'plugin' ? tt('canvas.skills.configSourcePlugin') : tt('canvas.skills.configSourceSkill')}

{props.config.fields.length === 0 ? null :
{props.config.fields.map(field => { const stored = props.config.values.find(value => value.id === field.id) const set = stored?.set === true && !touched.has(field.id) return })}
}
{props.config.applicable ? : null}
{message === null ? null :

{message}

} {steps === null || steps.length === 0 ? null :
    {steps.map((step, index) =>
  • {step.kind === 'command' ? '▶' : '▤'} {step.detail} {step.output === undefined ? null : {step.output}}
  • )}
}
} /** * Skill library manager (dock entry 「技能库」). One dialog covers the whole * lifecycle: what is installed, install from URLs, install from an uploaded * archive, per-skill configuration declared by the skill itself, and uninstall. * Installs are obviously consequential (they write into the user's * `~/.dsh/skills`), so every action reports its own result instead of acting * silently. */ function SkillLibraryDialog(props: { library: CanvasSkillLibrary | null loading: boolean busy: boolean /** Prefill for the URL field, e.g. the upstream of a skill just clicked. */ presetUrl: string /** Skill whose configuration the user asked to open (from the node picker). */ focusSkill?: string onReload: () => void onInstallUrls: (urls: string[], force: boolean) => void onInstallArchive: (file: File, force: boolean) => void onRemove: (name: string) => void onSaveConfig: (name: string, values: Array<{ id: string; value: string }>) => Promise onApplyConfig: (name: string) => Promise onClose: () => void }): React.JSX.Element { const [urls, setUrls] = useState(props.presetUrl) const [force, setForce] = useState(false) const [removing, setRemoving] = useState(null) const [dropActive, setDropActive] = useState(false) const [configOpen, setConfigOpen] = useState(props.focusSkill ?? null) const archiveRef = useRef(null) useEffect(() => { if (props.presetUrl !== '') setUrls(props.presetUrl) }, [props.presetUrl]) useEffect(() => { if (props.focusSkill !== undefined && props.focusSkill !== '') setConfigOpen(props.focusSkill) }, [props.focusSkill]) const sourceList = urls.split(/\r?\n/).map(line => line.trim()).filter(line => line !== '') const entries = props.library?.entries ?? [] const pickArchive = (file: File | undefined): void => { if (file !== undefined) props.onInstallArchive(file, force) } return createPortal(<>
{tt('canvas.skills.libraryTitle')} {entries.length > 0 ? {tt('canvas.skills.libraryInstalled', { count: entries.length })} : null}
{tt('canvas.skills.libraryInstall')}

{tt('canvas.skills.libraryInstallHint')}