diff --git a/CHANGELOG.md b/CHANGELOG.md index 80198db54..fc213e949 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ - Hide, lock, and constrain layers in design JSX with `visible={false}`, `locked`, and `constraints={{ horizontal, vertical }}`, set italic text with `italic`, and describe strokes fully with `strokes`, `strokeWeights`, `strokeCap`, `strokeJoin`, and the node-level `dashPattern`. JSX export now writes these together with stacked, gradient, and image fills, every effect, absolutely positioned children, size limits, vertical text alignment, masks, and variable bindings, so rendering exported JSX reproduces them and `diff_jsx` reports changes to them. - Jump between pages from the command palette: it lists the pages you visited recently in the tab, **Go to page…** lists every page, and typing a page name finds it. - Choose how much an AI model thinks for each message from the chat composer, from Off to Extra high or the provider default. Anthropic, Google, DeepSeek, OpenAI, OpenRouter, and compatible models apply it, where reasoning effort previously reached only OpenAI and OpenRouter models. A model profile's thinking level, which replaces its free-text reasoning effort, sets the starting choice, and finished reasoning shows how long the model thought. +- Read AI tool calls at a glance: each call shows a one-line summary and buttons that bring the layers it touched into view, even on another page. Expanded, it shows highlighted JSX, scripts, and JSON input and output, and exported images inline. A render call's JSX streams in as the model writes it, and a long run folds its earlier steps into one row. - Preview designs progressively on the canvas as direct AI providers stream JSX, without saving partial designs or adding intermediate undo steps. A preview stays with its page: it hides while you view another page and returns when you come back. - Write design trees as TSX with `@open-pencil/design-jsx` as the JSX import source, and render them with `renderTree`. - Swap the component behind an instance with `instance.swapComponent(component)` in the plugin API, as in Figma. diff --git a/bun.lock b/bun.lock index ecf557302..b637b89e3 100644 --- a/bun.lock +++ b/bun.lock @@ -18,6 +18,7 @@ "@codemirror/commands": "^6.11.0", "@codemirror/lang-html": "^6.4.12", "@codemirror/lang-javascript": "^6.2.5", + "@codemirror/lang-json": "^6.0.2", "@codemirror/language": "^6.12.4", "@codemirror/lint": "^6.9.7", "@codemirror/search": "^6.7.1", @@ -896,6 +897,8 @@ "@codemirror/lang-javascript": ["@codemirror/lang-javascript@6.2.5", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/lint": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/javascript": "^1.0.0" } }, "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A=="], + "@codemirror/lang-json": ["@codemirror/lang-json@6.0.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/json": "^1.0.0" } }, "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ=="], + "@codemirror/language": ["@codemirror/language@6.12.4", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A=="], "@codemirror/lint": ["@codemirror/lint@6.9.7", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.42.0", "crelt": "^1.0.5" } }, "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg=="], @@ -1318,6 +1321,8 @@ "@lezer/javascript": ["@lezer/javascript@1.5.4", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.1.3", "@lezer/lr": "^1.3.0" } }, "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA=="], + "@lezer/json": ["@lezer/json@1.0.3", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ=="], + "@lezer/lr": ["@lezer/lr@1.4.10", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A=="], "@libp2p/bootstrap": ["@libp2p/bootstrap@11.0.42", "", { "dependencies": { "@libp2p/interface": "^2.10.4", "@libp2p/interface-internal": "^2.3.17", "@libp2p/peer-id": "^5.1.7", "@multiformats/mafmt": "^12.1.6", "@multiformats/multiaddr": "^12.4.4", "main-event": "^1.0.1" } }, "sha512-xe5LMZrXR2cnFcR69ax/cHqNCc8zwSvqDWOf82u/ifAsUae7M0eeoEVi15lfrPewWst4YPkTqYDkZSPWC65I3Q=="], diff --git a/package.json b/package.json index e105d9a2e..07f33dd71 100644 --- a/package.json +++ b/package.json @@ -112,6 +112,7 @@ "@codemirror/commands": "^6.11.0", "@codemirror/lang-html": "^6.4.12", "@codemirror/lang-javascript": "^6.2.5", + "@codemirror/lang-json": "^6.0.2", "@codemirror/language": "^6.12.4", "@codemirror/lint": "^6.9.7", "@codemirror/search": "^6.7.1", diff --git a/packages/vue/src/i18n/locales/de/ai.json b/packages/vue/src/i18n/locales/de/ai.json index ad6d48337..44223fdc6 100644 --- a/packages/vue/src/i18n/locales/de/ai.json +++ b/packages/vue/src/i18n/locales/de/ai.json @@ -34,6 +34,12 @@ "toolFinished": "Fertig", "toolRunning": "Wird ausgeführt…", "toolError": "Fehler", + "toolInput": "Eingabe", + "toolOutput": "Ausgabe", + "toolDetails": "Details des Werkzeugaufrufs", + "toolSteps": "Frühere Schritte: {count}", + "toolStepsFailed": "fehlgeschlagen: {count}", + "showNodeOnCanvas": "{name} auf der Arbeitsfläche zeigen", "unknownTool": "Unbekanntes Werkzeug", "permissionRequestTitle": "Berechtigungsanfrage", "permissionRequest": "{tool} fordert eine Berechtigung an.", diff --git a/packages/vue/src/i18n/locales/es/ai.json b/packages/vue/src/i18n/locales/es/ai.json index e39348fe5..2d184f0b6 100644 --- a/packages/vue/src/i18n/locales/es/ai.json +++ b/packages/vue/src/i18n/locales/es/ai.json @@ -34,6 +34,12 @@ "toolFinished": "Hecho", "toolRunning": "Ejecutando…", "toolError": "Error", + "toolInput": "Entrada", + "toolOutput": "Salida", + "toolDetails": "Detalles de la llamada a la herramienta", + "toolSteps": "Pasos anteriores: {count}", + "toolStepsFailed": "con errores: {count}", + "showNodeOnCanvas": "Mostrar {name} en el lienzo", "unknownTool": "Herramienta desconocida", "permissionRequestTitle": "Solicitud de permiso", "permissionRequest": "{tool} solicita permiso.", diff --git a/packages/vue/src/i18n/locales/fr/ai.json b/packages/vue/src/i18n/locales/fr/ai.json index bd8942c55..c192673d7 100644 --- a/packages/vue/src/i18n/locales/fr/ai.json +++ b/packages/vue/src/i18n/locales/fr/ai.json @@ -34,6 +34,12 @@ "toolFinished": "Terminé", "toolRunning": "Exécution…", "toolError": "Erreur", + "toolInput": "Entrée", + "toolOutput": "Sortie", + "toolDetails": "Détails de l’appel d’outil", + "toolSteps": "Étapes précédentes : {count}", + "toolStepsFailed": "en échec : {count}", + "showNodeOnCanvas": "Afficher {name} sur le canevas", "unknownTool": "Outil inconnu", "permissionRequestTitle": "Demande d’autorisation", "permissionRequest": "{tool} demande une autorisation.", diff --git a/packages/vue/src/i18n/locales/it/ai.json b/packages/vue/src/i18n/locales/it/ai.json index c30a09fb7..2c7bd33f7 100644 --- a/packages/vue/src/i18n/locales/it/ai.json +++ b/packages/vue/src/i18n/locales/it/ai.json @@ -34,6 +34,12 @@ "toolFinished": "Fine", "toolRunning": "Esecuzione…", "toolError": "Errore", + "toolInput": "Dati in ingresso", + "toolOutput": "Risultato", + "toolDetails": "Dettagli della chiamata allo strumento", + "toolSteps": "Passaggi precedenti: {count}", + "toolStepsFailed": "non riusciti: {count}", + "showNodeOnCanvas": "Mostra {name} nella tela", "unknownTool": "Strumento sconosciuto", "permissionRequestTitle": "Richiesta di autorizzazione", "permissionRequest": "{tool} richiede l’autorizzazione.", diff --git a/packages/vue/src/i18n/locales/ja/ai.json b/packages/vue/src/i18n/locales/ja/ai.json index 5d103ff4a..36093ca17 100644 --- a/packages/vue/src/i18n/locales/ja/ai.json +++ b/packages/vue/src/i18n/locales/ja/ai.json @@ -34,6 +34,12 @@ "toolFinished": "完了", "toolRunning": "実行中…", "toolError": "エラー", + "toolInput": "入力", + "toolOutput": "出力", + "toolDetails": "ツール呼び出しの詳細", + "toolSteps": "以前のステップ: {count}", + "toolStepsFailed": "失敗: {count}", + "showNodeOnCanvas": "{name} をキャンバスに表示", "unknownTool": "不明なツール", "permissionRequestTitle": "権限リクエスト", "permissionRequest": "{tool}が権限を要求しています。", diff --git a/packages/vue/src/i18n/locales/pl/ai.json b/packages/vue/src/i18n/locales/pl/ai.json index efd73a76a..92eb60098 100644 --- a/packages/vue/src/i18n/locales/pl/ai.json +++ b/packages/vue/src/i18n/locales/pl/ai.json @@ -34,6 +34,12 @@ "toolFinished": "Gotowe", "toolRunning": "Uruchamianie…", "toolError": "Błąd", + "toolInput": "Wejście", + "toolOutput": "Wyjście", + "toolDetails": "Szczegóły wywołania narzędzia", + "toolSteps": "Wcześniejsze kroki: {count}", + "toolStepsFailed": "nieudane: {count}", + "showNodeOnCanvas": "Pokaż {name} na kanwie", "unknownTool": "Nieznane narzędzie", "permissionRequestTitle": "Prośba o uprawnienie", "permissionRequest": "{tool} prosi o uprawnienie.", diff --git a/packages/vue/src/i18n/locales/ru/ai.json b/packages/vue/src/i18n/locales/ru/ai.json index 1e233b35e..5f62e66ba 100644 --- a/packages/vue/src/i18n/locales/ru/ai.json +++ b/packages/vue/src/i18n/locales/ru/ai.json @@ -34,6 +34,12 @@ "toolFinished": "Готово", "toolRunning": "Выполняется…", "toolError": "Ошибка", + "toolInput": "Вход", + "toolOutput": "Результат", + "toolDetails": "Подробности вызова инструмента", + "toolSteps": "Предыдущие шаги: {count}", + "toolStepsFailed": "с ошибкой: {count}", + "showNodeOnCanvas": "Показать {name} на холсте", "unknownTool": "Неизвестный инструмент", "permissionRequestTitle": "Запрос разрешения", "permissionRequest": "Инструмент «{tool}» запрашивает разрешение.", diff --git a/packages/vue/src/i18n/locales/zh-cn/ai.json b/packages/vue/src/i18n/locales/zh-cn/ai.json index 1bc67eb74..9e82af4e3 100644 --- a/packages/vue/src/i18n/locales/zh-cn/ai.json +++ b/packages/vue/src/i18n/locales/zh-cn/ai.json @@ -34,6 +34,12 @@ "toolFinished": "完成", "toolRunning": "正在运行…", "toolError": "错误", + "toolInput": "输入", + "toolOutput": "输出", + "toolDetails": "工具调用详情", + "toolSteps": "之前的步骤:{count}", + "toolStepsFailed": "失败:{count}", + "showNodeOnCanvas": "在画布上显示 {name}", "unknownTool": "未知工具", "permissionRequestTitle": "权限请求", "permissionRequest": "{tool} 正在请求权限。", diff --git a/packages/vue/src/i18n/messages/ai.ts b/packages/vue/src/i18n/messages/ai.ts index 0bef4ccbf..60e7073a9 100644 --- a/packages/vue/src/i18n/messages/ai.ts +++ b/packages/vue/src/i18n/messages/ai.ts @@ -76,6 +76,12 @@ export const aiMessageDefaults = { toolFinished: 'Done', toolRunning: 'Running…', toolError: 'Error', + toolInput: 'Input', + toolOutput: 'Output', + toolDetails: 'Tool call details', + toolSteps: params('Earlier steps: {count}'), + toolStepsFailed: params('failed: {count}'), + showNodeOnCanvas: params('Show {name} on canvas'), unknownTool: 'Unknown tool', permissionRequestTitle: 'Permission request', permissionRequest: params('{tool} is requesting permission.'), diff --git a/src/app/ai/chat/tool-calls/display.ts b/src/app/ai/chat/tool-calls/display.ts new file mode 100644 index 000000000..fd37ff57f --- /dev/null +++ b/src/app/ai/chat/tool-calls/display.ts @@ -0,0 +1,148 @@ +import { getToolName, isToolUIPart } from 'ai' +import type { UIDataTypes, UIMessagePart, UITools } from 'ai' +import { isValid } from 'js-base64' + +export type ChatMessagePart = UIMessagePart +export type ToolCallPart = Extract + +const MCP_PREFIX = /^mcp__[^_]+__/ +const SUMMARY_LENGTH = 60 +const IMAGE_MEDIA_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']) + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function truncate(text: string): string { + const line = text.trim().split('\n')[0]?.trim() ?? '' + return line.length > SUMMARY_LENGTH ? `${line.slice(0, SUMMARY_LENGTH - 1)}…` : line +} + +export function toolName(part: ToolCallPart): string { + return getToolName(part).replace(MCP_PREFIX, '') +} + +export function toolDisplayName(part: ToolCallPart): string { + return toolName(part) + .replace(/_/g, ' ') + .replace(/\b\w/g, (c) => c.toUpperCase()) +} + +/** The root element's opening tag, which may still be streaming in. */ +const ROOT_TAG = /<([A-Z][\w.]*)([^>]*)/ + +/** The element and name a JSX source opens with, such as `Frame "Pricing"`. */ +function jsxSummary(jsx: string): string { + const root = ROOT_TAG.exec(jsx) + if (!root) return '' + const [, element, attributes = ''] = root + // Only the root's own attributes: a child's name must not label the root. + const name = /\bname=["'{]+([^"'}]+)/.exec(attributes)?.[1] + return name ? `${element} “${truncate(name)}”` : element +} + +const SUMMARY_FIELDS = ['name', 'query', 'text', 'question', 'path', 'id'] as const + +/** A one-line description of what a call does, read from its (possibly partial) input. */ +export function toolSummary(part: ToolCallPart): string { + const input = part.input + if (!isRecord(input)) return '' + if (typeof input.jsx === 'string') return jsxSummary(input.jsx) + if (typeof input.code === 'string') return truncate(input.code) + if (Array.isArray(input.ids) && input.ids.length > 1) return `${input.ids.length} nodes` + for (const field of SUMMARY_FIELDS) { + const value = input[field] + if (typeof value === 'string' && value.trim()) return truncate(value) + } + return '' +} + +function stringIds(value: unknown): string[] { + if (typeof value === 'string') return [value] + if (!Array.isArray(value)) return [] + return value.flatMap((item) => { + if (typeof item === 'string') return [item] + return isRecord(item) && typeof item.id === 'string' ? [item.id] : [] + }) +} + +/** Nodes a call targeted or produced, for chips that bring them into view. */ +export function toolNodeIds(part: ToolCallPart): string[] { + const ids = new Set() + const { input } = part + if (isRecord(input)) { + for (const key of ['id', 'ids', 'replace_id', 'parent_id', 'parentId']) { + for (const id of stringIds(input[key])) ids.add(id) + } + } + if (part.state === 'output-available' && isRecord(part.output)) { + const { output } = part + if (typeof output.deleted !== 'string') { + for (const key of ['id', 'ids', 'selection', 'results', 'created']) { + for (const id of stringIds(output[key])) ids.add(id) + } + } + } + return [...ids] +} + +export function toolErrorText(part: ToolCallPart): string | null { + if (part.state === 'output-error') return part.errorText + if (part.state !== 'output-available' || !isRecord(part.output)) return null + const { error } = part.output + return typeof error === 'string' ? error : null +} + +/** An image the tool returned for the model, such as `export_image`, as a data URL. */ +export function toolImage(part: ToolCallPart): string | null { + if (part.state !== 'output-available' || !isRecord(part.output)) return null + const { base64, mimeType } = part.output + if (typeof base64 !== 'string' || typeof mimeType !== 'string') return null + if (!IMAGE_MEDIA_TYPES.has(mimeType) || !isValid(base64)) return null + return `data:${mimeType};base64,${base64}` +} + +/** Output with large binary payloads elided, for display as JSON. */ +export function displayedToolOutput(part: ToolCallPart): unknown { + if (part.state !== 'output-available') return undefined + const { output } = part + if (!isRecord(output) || typeof output.base64 !== 'string') return output + return { ...output, base64: `<${output.base64.length} base64 characters>` } +} + +/** The source a call writes, shown highlighted instead of as a JSON string. */ +export function toolSource( + part: ToolCallPart +): { code: string; language: 'design-jsx' | 'javascript' } | null { + const input = part.input + if (!isRecord(input)) return null + if (typeof input.jsx === 'string') return { code: input.jsx, language: 'design-jsx' } + if (typeof input.code === 'string') return { code: input.code, language: 'javascript' } + return null +} + +/** Whether a call has input worth showing: source, or at least one argument. */ +export function toolHasInput(part: ToolCallPart): boolean { + return toolSource(part) !== null || (isRecord(part.input) && Object.keys(part.input).length > 0) +} + +export type MessagePartGroup = + | { kind: 'part'; part: ChatMessagePart; index: number } + | { kind: 'tools'; parts: { part: ToolCallPart; index: number }[] } + +/** Consecutive tool calls form one group, so a long run can collapse into a single row. */ +export function groupMessageParts(parts: readonly ChatMessagePart[]): MessagePartGroup[] { + const groups: MessagePartGroup[] = [] + parts.forEach((part, index) => { + // Step boundaries are not rendered, so they must not split a run of calls. + if (part.type === 'step-start') return + if (!isToolUIPart(part)) { + groups.push({ kind: 'part', part, index }) + return + } + const last = groups.at(-1) + if (last?.kind === 'tools') last.parts.push({ part, index }) + else groups.push({ kind: 'tools', parts: [{ part, index }] }) + }) + return groups +} diff --git a/src/app/editor/selection/focus.ts b/src/app/editor/selection/focus.ts index 8c6bf46e2..bcd90d67e 100644 --- a/src/app/editor/selection/focus.ts +++ b/src/app/editor/selection/focus.ts @@ -46,6 +46,36 @@ export function focusNodes(store: FocusStore, ids: readonly string[]): boolean { return true } +function pageIdOf(graph: FocusStore['graph'], id: string): string | null { + let node = graph.getNode(id) + while (node && node.type !== 'CANVAS') + node = node.parentId ? graph.getNode(node.parentId) : undefined + return node?.id ?? null +} + +/** + * Focuses the nodes that share the first present node's page, switching to that page. + * An AI run can work on a page other than the one on screen. + */ +export async function focusNodesOnTheirPage( + store: FocusStore, + ids: readonly string[] +): Promise { + const pageIds = new Map(ids.map((id) => [id, pageIdOf(store.graph, id)])) + const pageId = [...pageIds.values()].find((id) => id !== null) + if (!pageId) return false + if (pageId !== store.state.currentPageId) { + const switches = store.pageSwitchCount() + await store.switchPage(pageId) + if (store.pageSwitchCount() !== switches + 1 || store.state.currentPageId !== pageId) + return false + } + return focusNodes( + store, + ids.filter((id) => pageIds.get(id) === pageId) + ) +} + /** `superseded` when the user switched pages while other pages were being searched. */ export type FocusByNameResult = 'found' | 'missing' | 'superseded' diff --git a/src/components/ChatPanel.vue b/src/components/ChatPanel.vue index d89bc6279..2d4b50b40 100644 --- a/src/components/ChatPanel.vue +++ b/src/components/ChatPanel.vue @@ -189,6 +189,7 @@ function handleStop() { :messages="messages" :status="status" :show-continue="showContinue" + :nodes-live="!history.readOnly.value" @continue=" submission.submit({ modelText: 'Continue where you left off', diff --git a/src/components/chat/ChatMessage.vue b/src/components/chat/ChatMessage.vue index 18f694793..22d74a791 100644 --- a/src/components/chat/ChatMessage.vue +++ b/src/components/chat/ChatMessage.vue @@ -1,8 +1,7 @@ @@ -92,80 +67,34 @@ function partKey(part: UIMessagePart, index: number): stri :class="message.role === 'user' ? 'max-w-[85%]' : ''" >