From 35327eca3406d87157e5cac2f7f021fbf8118c1b Mon Sep 17 00:00:00 2001 From: Daniel Ehrhardt Date: Fri, 23 Oct 2026 17:58:14 +0200 Subject: [PATCH 1/4] Artifacts: dashboards and small apps agents build and you change by chatting Agents build a small web app (one HTML document plus JSON data) with artifact_create and change it with artifact_update. It opens next to the chat in a sandboxed frame served by the core, with a window.godmode bridge for data, saving, asking the agent and the theme. Versions with restore, refresh through the artifact's chat, an Artifacts page with live previews and starters, and a full-screen view with a line to ask for changes. --- apps/desktop/src-tauri/tauri.conf.json | 3 +- apps/desktop/src/App.tsx | 4 + .../components/artifacts/artifact-frame.tsx | 234 ++++++++ .../components/artifacts/artifact-panel.tsx | 174 ++++++ .../components/artifacts/artifact-view.tsx | 556 ++++++++++++++++++ .../src/components/chat/message-blocks.tsx | 7 +- apps/desktop/src/components/chat/tool-meta.ts | 12 + .../src/components/layout/app-shell.tsx | 3 + .../src/components/workspaces/scope-dnd.tsx | 1 + apps/desktop/src/lib/api.ts | 25 + apps/desktop/src/lib/hooks.ts | 19 + apps/desktop/src/lib/mode.ts | 1 + apps/desktop/src/lib/queryKeys.ts | 6 + apps/desktop/src/lib/realtime.ts | 1 + .../src/pages/artifacts/artifact-page.tsx | 79 +++ .../src/pages/artifacts/artifacts-page.tsx | 222 +++++++ .../src/pages/chat/chat-conversation.tsx | 28 +- docs/ARCHITECTURE.md | 40 ++ docs/FEATURES.md | 1 + packages/core/src/db/migrations.ts | 33 ++ packages/core/src/mcp/tools.ts | 100 ++++ packages/core/src/runner/prompt.ts | 7 + packages/core/src/runner/runner.ts | 1 + packages/core/src/server/app.ts | 10 + packages/core/src/server/artifactFrame.ts | 45 ++ packages/core/src/server/routes/artifacts.ts | 73 +++ packages/core/src/services/artifacts.ts | 362 ++++++++++++ packages/core/src/services/scopeMoves.ts | 1 + packages/core/test/artifacts.test.ts | 240 ++++++++ packages/core/test/fixtures/fake-claude.ts | 22 + packages/shared/src/activity.ts | 8 + packages/shared/src/api.ts | 3 +- packages/shared/src/artifacts.ts | 154 +++++ packages/shared/src/events.ts | 4 +- packages/shared/src/index.ts | 1 + 35 files changed, 2470 insertions(+), 10 deletions(-) create mode 100644 apps/desktop/src/components/artifacts/artifact-frame.tsx create mode 100644 apps/desktop/src/components/artifacts/artifact-panel.tsx create mode 100644 apps/desktop/src/components/artifacts/artifact-view.tsx create mode 100644 apps/desktop/src/pages/artifacts/artifact-page.tsx create mode 100644 apps/desktop/src/pages/artifacts/artifacts-page.tsx create mode 100644 packages/core/src/server/artifactFrame.ts create mode 100644 packages/core/src/server/routes/artifacts.ts create mode 100644 packages/core/src/services/artifacts.ts create mode 100644 packages/core/test/artifacts.test.ts create mode 100644 packages/shared/src/artifacts.ts diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 75717a03..cddb31e2 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -40,7 +40,8 @@ "media-src": "'self' blob: data:", "style-src": "'self' 'unsafe-inline'", "font-src": "'self' data:", - "script-src": "'self' 'wasm-unsafe-eval'" + "script-src": "'self' 'wasm-unsafe-eval'", + "frame-src": "http://127.0.0.1:* http://localhost:*" }, "dangerousDisableAssetCspModification": [ "style-src" diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index c9f90780..1091fc0b 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -25,6 +25,8 @@ const AgentsPage = lazy(() => import("@/pages/agents/agents-page")); const AgentNewPage = lazy(() => import("@/pages/agents/agent-new")); const AgentDetailPage = lazy(() => import("@/pages/agents/agent-detail")); const FilesPage = lazy(() => import("@/pages/files/files-page")); +const ArtifactsPage = lazy(() => import("@/pages/artifacts/artifacts-page")); +const ArtifactPage = lazy(() => import("@/pages/artifacts/artifact-page")); const AutomationsPage = lazy(() => import("@/pages/automations/automations-page")); const PluginsPage = lazy(() => import("@/pages/plugins/plugins-page")); const ActivityPage = lazy(() => import("@/pages/activity/activity-page")); @@ -203,6 +205,8 @@ export function App() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/apps/desktop/src/components/artifacts/artifact-frame.tsx b/apps/desktop/src/components/artifacts/artifact-frame.tsx new file mode 100644 index 00000000..a200412e --- /dev/null +++ b/apps/desktop/src/components/artifacts/artifact-frame.tsx @@ -0,0 +1,234 @@ +import { useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; +import { MonitorSmartphone } from "lucide-react"; +import { cloudContext, coreUrl } from "@/lib/core"; +import { useTheme } from "@/components/theme-provider"; +import { cn } from "@/lib/utils"; + +/** + * Runs an artifact's HTML in the core's sandbox page (`/api/artifacts/frame`): an opaque origin with its own policy, so + * the code can load CDN libraries but never reaches the app, its token or Godmode's API. The document gets + * `window.godmode` (ARTIFACT_BRIDGE_DOC in @godmode/shared) and talks to the app only through postMessage. + */ + +const LIGHT = { + bg: "#faf9f5", + card: "#ffffff", + fg: "#1a1a1a", + muted: "#75716a", + border: "rgb(38 32 22 / 0.1)", + accent: "#0eca7b", + "accent-fg": "#04130c", + positive: "#087f4d", + negative: "#d4101f", +}; + +const DARK = { + bg: "#1c1b19", + card: "#232220", + fg: "#f4f2ed", + muted: "#a29e96", + border: "rgb(255 250 240 / 0.09)", + accent: "#2fd690", + "accent-fg": "#06140d", + positive: "#2fd690", + negative: "#f0525b", +}; + +const FONT = `ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif`; + +const vars = (p: Record) => Object.entries(p).map(([k, v]) => `--gm-${k}:${v};`).join("") + `--gm-font:${FONT};`; + +const BASE_STYLE = `:root{${vars(LIGHT)}color-scheme:light}:root.dark{${vars(DARK)}color-scheme:dark}html,body{margin:0}body{background:var(--gm-bg);color:var(--gm-fg);font-family:var(--gm-font);-webkit-font-smoothing:antialiased}`; + +/** Runs first in the artifact's document. Kept as plain ES5 so any page can carry it. */ +function bridgeScript(token: string, data: unknown, theme: "light" | "dark", standalone = false): string { + const json = (v: unknown) => JSON.stringify(v ?? null).replace(/ `\\u${c.charCodeAt(0).toString(16)}`); + return ``; +} + +/** The artifact's HTML with the bridge first in (a fragment gets a document around it). */ +export function artifactDocument(html: string, bridge: string): string { + const head = /]*)?>/i.exec(html); + if (head) return html.slice(0, head.index + head[0].length) + bridge + html.slice(head.index + head[0].length); + const root = /]*)?>/i.exec(html); + if (root) return html.slice(0, root.index + root[0].length) + `${bridge}` + html.slice(root.index + root[0].length); + const doctype = /]*>/i.exec(html); + const body = doctype ? html.slice(doctype.index + doctype[0].length) : html; + return `${bridge}${body}`; +} + +/** A standalone copy to download: same bridge, data saved in the file, setData kept in memory. */ +export function standaloneDocument(html: string, data: unknown, theme: "light" | "dark"): string { + return artifactDocument(html, bridgeScript("standalone", data, theme, true)); +} + +function hashOf(text: string): string { + let h = 5381; + for (let i = 0; i < text.length; i++) h = ((h << 5) + h + text.charCodeAt(i)) | 0; + return (h >>> 0).toString(36); +} + +const randomToken = () => Array.from(crypto.getRandomValues(new Uint8Array(12)), (b) => b.toString(16).padStart(2, "0")).join(""); + +export interface ArtifactFrameHandlers { + /** The app saved data (`godmode.setData`); reject to tell the app it wasn't saved. */ + onSetData?: (data: unknown) => Promise; + onAsk?: (text: string) => void; + onRefresh?: () => void; + onError?: (message: string) => void; +} + +export function ArtifactFrame({ + html, + data, + title, + className, + style, + interactive = true, + lazy = false, + ...handlers +}: { + html: string; + data: unknown; + title: string; + className?: string; + style?: CSSProperties; + /** Off for thumbnails: no clicks, nothing saved or sent. */ + interactive?: boolean; + lazy?: boolean; +} & ArtifactFrameHandlers) { + const { resolved } = useTheme(); + const ref = useRef(null); + const handlersRef = useRef(handlers); + handlersRef.current = handlers; + const key = useMemo(() => hashOf(html), [html]); + // One token per loaded document; what it said it saved isn't sent back to it. + const session = useRef<{ key: string; token: string; loaded: boolean; lastData: string }>({ key: "", token: "", loaded: false, lastData: "" }); + if (session.current.key !== key) session.current = { key, token: randomToken(), loaded: false, lastData: JSON.stringify(data ?? null) }; + const latest = useRef({ html, data, theme: resolved }); + latest.current = { html, data, theme: resolved }; + + useEffect(() => { + const onMessage = (e: MessageEvent) => { + const frame = ref.current?.contentWindow; + if (!frame || e.source !== frame) return; + const m = e.data as { type?: string; __gm?: string; id?: number; data?: unknown; text?: string; message?: string } | null; + if (!m || typeof m !== "object") return; + const s = session.current; + if (m.type === "gm:shell") { + const { html: h, data: d, theme } = latest.current; + s.loaded = true; + s.lastData = JSON.stringify(d ?? null); + frame.postMessage({ type: "gm:load", doc: artifactDocument(h, bridgeScript(s.token, d, theme)) }, "*"); + return; + } + if (m.__gm !== s.token || !interactive) return; + const h = handlersRef.current; + if (m.type === "setData") { + const text = JSON.stringify(m.data ?? null); + s.lastData = text; + const reply = (ok: boolean, error?: string) => frame.postMessage({ __gm: s.token, type: "reply", id: m.id, ok, error }, "*"); + if (!h.onSetData) return reply(false, "This view can't save"); + h.onSetData(m.data).then( + () => reply(true), + (err: unknown) => reply(false, err instanceof Error ? err.message : "Not saved"), + ); + } else if (m.type === "ask" && m.text?.trim()) h.onAsk?.(m.text.trim().slice(0, 4000)); + else if (m.type === "refresh") h.onRefresh?.(); + else if (m.type === "error" && m.message) h.onError?.(m.message.slice(0, 500)); + }; + window.addEventListener("message", onMessage); + return () => window.removeEventListener("message", onMessage); + }, [interactive]); + + // New data from the agent or another window: hand it over without reloading. + useEffect(() => { + const s = session.current; + const text = JSON.stringify(data ?? null); + if (!s.loaded || text === s.lastData) return; + s.lastData = text; + ref.current?.contentWindow?.postMessage({ __gm: s.token, type: "data", data: data ?? null }, "*"); + }, [data]); + + useEffect(() => { + const s = session.current; + if (s.loaded) ref.current?.contentWindow?.postMessage({ __gm: s.token, type: "theme", theme: resolved }, "*"); + }, [resolved]); + + if (cloudContext) { + return ( +
+
+ +

Open it on your computer

+

Artifacts run in the Godmode app or the dashboard on your computer, not through Godmode Cloud.

+
+
+ ); + } + + return ( +