diff --git a/README.md b/README.md index 8e53dcaa..8880aca5 100644 --- a/README.md +++ b/README.md @@ -60,3 +60,9 @@ pnpm turbo run storybook:build --filter=storybook - `packages/runtime` - Server-side runtime handlers - `packages/shared` - Common utilities - `apps/storybook` - Component documentation and examples + +### Inspector and DevTools packages + +- `packages/web-inspector` - Lit-based `` custom element that attaches to a live `CopilotKitCore` to mirror runtime status, agents, tools, context, and AG-UI events (caps 200 per agent / 500 total). Includes persistence helpers for layout/dock state. +- `packages/devtools-inspector` - Proof-of-concept host that depends on `@copilotkitnext/web-inspector`, registers the element, and injects a remote core/agent shim so streamed data can render inside DevTools. +- `packages/devtools-extension` - Chrome DevTools MV3 extension scaffold (background/content/page scripts, devtools panel) that relays CopilotKit data from the inspected page to the `devtools-inspector` host and renders a “CopilotKit” panel. diff --git a/apps/angular/demo/package.json b/apps/angular/demo/package.json index ad81164b..8e2e081c 100644 --- a/apps/angular/demo/package.json +++ b/apps/angular/demo/package.json @@ -20,6 +20,7 @@ "@angular/platform-browser": "^18.2.0", "@angular/platform-browser-dynamic": "^18.2.0", "@copilotkitnext/angular": "workspace:*", + "@copilotkitnext/web-inspector": "workspace:*", "rxjs": "^7.8.1", "tslib": "^2.8.1", "zod": "^3.25.75", diff --git a/apps/angular/demo/src/app/app.config.ts b/apps/angular/demo/src/app/app.config.ts index d2c2747d..a057cf31 100644 --- a/apps/angular/demo/src/app/app.config.ts +++ b/apps/angular/demo/src/app/app.config.ts @@ -1,9 +1,6 @@ import { ApplicationConfig, importProvidersFrom } from "@angular/core"; import { BrowserModule } from "@angular/platform-browser"; -import { - provideCopilotKit, - provideCopilotChatLabels, -} from "@copilotkitnext/angular"; +import { provideCopilotKit, provideCopilotChatLabels, provideCopilotKitDevtools } from "@copilotkitnext/angular"; import { WildcardToolRenderComponent } from "./components/wildcard-tool-render.component"; export const appConfig: ApplicationConfig = { @@ -20,10 +17,10 @@ export const appConfig: ApplicationConfig = { frontendTools: [], humanInTheLoop: [], }), + provideCopilotKitDevtools(), provideCopilotChatLabels({ chatInputPlaceholder: "Ask me anything...", - chatDisclaimerText: - "CopilotKit Angular Demo - AI responses may need verification.", + chatDisclaimerText: "CopilotKit Angular Demo - AI responses may need verification.", }), ], }; diff --git a/apps/angular/demo/src/app/routes/headless/headless-chat.component.ts b/apps/angular/demo/src/app/routes/headless/headless-chat.component.ts index 89f0f6e4..54b360b0 100644 --- a/apps/angular/demo/src/app/routes/headless/headless-chat.component.ts +++ b/apps/angular/demo/src/app/routes/headless/headless-chat.component.ts @@ -1,4 +1,4 @@ -import { Component, ChangeDetectionStrategy, computed, inject, input, signal } from "@angular/core"; +import { Component, ChangeDetectionStrategy, computed, inject, input, signal, OnDestroy, OnInit } from "@angular/core"; import { CommonModule } from "@angular/common"; import { FormsModule } from "@angular/forms"; import { @@ -10,6 +10,7 @@ import { registerHumanInTheLoop, } from "@copilotkitnext/angular"; import { RenderToolCalls } from "@copilotkitnext/angular"; +import { WEB_INSPECTOR_TAG, type WebInspectorElement } from "@copilotkitnext/web-inspector"; import { z } from "zod"; @Component({ @@ -76,7 +77,7 @@ export class RequireApprovalComponent implements HumanInTheLoopToolRenderer { `, }) -export class HeadlessChatComponent { +export class HeadlessChatComponent implements OnInit, OnDestroy { readonly agentStore = injectAgentStore("openai"); readonly agent = computed(() => this.agentStore()?.agent); readonly isRunning = computed(() => !!this.agentStore()?.isRunning()); @@ -84,6 +85,7 @@ export class HeadlessChatComponent { readonly copilotkit = inject(CopilotKit); inputValue = ""; + private inspectorElement: WebInspectorElement | null = null; constructor() { registerHumanInTheLoop({ @@ -104,6 +106,28 @@ export class HeadlessChatComponent { ); } + ngOnInit(): void { + if (typeof document === "undefined") return; + + const existing = document.querySelector(WEB_INSPECTOR_TAG); + const inspector = existing ?? (document.createElement(WEB_INSPECTOR_TAG) as WebInspectorElement); + inspector.core = this.copilotkit.core; + inspector.setAttribute("auto-attach-core", "false"); + + if (!existing) { + document.body.appendChild(inspector); + } + + this.inspectorElement = inspector; + } + + ngOnDestroy(): void { + if (this.inspectorElement && this.inspectorElement.isConnected) { + this.inspectorElement.remove(); + } + this.inspectorElement = null; + } + async send() { const content = this.inputValue.trim(); const agent = this.agent(); diff --git a/apps/angular/demo/tsconfig.json b/apps/angular/demo/tsconfig.json index 8b90a657..c1550f57 100644 --- a/apps/angular/demo/tsconfig.json +++ b/apps/angular/demo/tsconfig.json @@ -25,6 +25,10 @@ "../../packages/shared/dist/index.d.ts", "../../packages/shared/dist/index.mjs", "../../packages/shared/src/index.ts" + ], + "@copilotkitnext/web-inspector": [ + "../../packages/web-inspector/dist/index.d.ts", + "../../packages/web-inspector/src/index.ts" ] } }, diff --git a/packages/angular/src/lib/devtools.ts b/packages/angular/src/lib/devtools.ts new file mode 100644 index 00000000..e9acb92d --- /dev/null +++ b/packages/angular/src/lib/devtools.ts @@ -0,0 +1,35 @@ +import { APP_INITIALIZER, Provider } from "@angular/core"; +import { CopilotKit } from "./copilotkit"; + +type DevtoolsHook = { + core?: unknown; + setCore: (core: unknown) => void; +}; + +function initializeDevtools(copilotkit: CopilotKit): () => void { + return () => { + if (typeof window === "undefined") { + return; + } + + const globalWindow = window as typeof window & { __COPILOTKIT_DEVTOOLS__?: DevtoolsHook }; + const hook = globalWindow.__COPILOTKIT_DEVTOOLS__ ?? { + core: undefined, + setCore(nextCore: unknown) { + this.core = nextCore; + }, + }; + + hook.setCore(copilotkit.core); + globalWindow.__COPILOTKIT_DEVTOOLS__ = hook; + }; +} + +export function provideCopilotKitDevtools(): Provider { + return { + provide: APP_INITIALIZER, + useFactory: initializeDevtools, + deps: [CopilotKit], + multi: true, + }; +} diff --git a/packages/angular/src/public-api.ts b/packages/angular/src/public-api.ts index 0a97d13a..efb66a14 100644 --- a/packages/angular/src/public-api.ts +++ b/packages/angular/src/public-api.ts @@ -48,3 +48,4 @@ export * from "./lib/components/chat/copilot-chat-view-input-container"; export * from "./lib/components/chat/copilot-chat-view-scroll-to-bottom-button"; export * from "./lib/components/chat/copilot-chat-view-scroll-view"; export * from "./lib/components/chat/copilot-chat-view.types"; +export * from "./lib/devtools"; diff --git a/packages/devtools-extension/README.md b/packages/devtools-extension/README.md new file mode 100644 index 00000000..b9f91dd6 --- /dev/null +++ b/packages/devtools-extension/README.md @@ -0,0 +1,14 @@ +# CopilotKit DevTools extension + +Builds a Chrome DevTools panel that hosts the CopilotKit web inspector. + +## Commands + +- `pnpm --filter @copilotkitnext/devtools-extension build` – bundle scripts to `dist/` and copy the manifest/assets. +- `pnpm --filter @copilotkitnext/devtools-extension dev` – watch mode for local iteration. + +## Loading the extension + +1. Run the build command above. +2. In Chrome, open `chrome://extensions`, enable **Developer mode**, and click **Load unpacked**. +3. Select `packages/devtools-extension/dist`. A “CopilotKit” DevTools panel will appear when inspecting a tab. diff --git a/packages/devtools-extension/eslint.config.mjs b/packages/devtools-extension/eslint.config.mjs new file mode 100644 index 00000000..daa75ba7 --- /dev/null +++ b/packages/devtools-extension/eslint.config.mjs @@ -0,0 +1,3 @@ +import { config as baseConfig } from "@copilotkitnext/eslint-config/base"; + +export default [...baseConfig]; diff --git a/packages/devtools-extension/package.json b/packages/devtools-extension/package.json new file mode 100644 index 00000000..9336df55 --- /dev/null +++ b/packages/devtools-extension/package.json @@ -0,0 +1,30 @@ +{ + "name": "@copilotkitnext/devtools-extension", + "version": "0.0.1", + "private": true, + "description": "Chrome DevTools panel for CopilotKit", + "scripts": { + "build": "node ./scripts/build.mjs", + "dev": "node ./scripts/build.mjs --watch", + "lint": "eslint . --max-warnings 0", + "check-types": "tsc --noEmit", + "clean": "rm -rf dist" + }, + "dependencies": { + "@ag-ui/client": "0.0.42-alpha.1", + "@copilotkitnext/core": "workspace:*", + "@copilotkitnext/devtools-inspector": "workspace:*" + }, + "devDependencies": { + "@copilotkitnext/eslint-config": "workspace:*", + "@copilotkitnext/typescript-config": "workspace:*", + "@types/chrome": "^0.0.300", + "@types/node": "^22.15.3", + "esbuild": "^0.24.2", + "eslint": "^9.30.0", + "typescript": "5.8.2" + }, + "engines": { + "node": ">=18" + } +} diff --git a/packages/devtools-extension/public/devtools.html b/packages/devtools-extension/public/devtools.html new file mode 100644 index 00000000..c7d17d41 --- /dev/null +++ b/packages/devtools-extension/public/devtools.html @@ -0,0 +1,10 @@ + + + + + CopilotKit DevTools + + + + + diff --git a/packages/devtools-extension/public/manifest.chrome.json b/packages/devtools-extension/public/manifest.chrome.json new file mode 100644 index 00000000..2f527dc8 --- /dev/null +++ b/packages/devtools-extension/public/manifest.chrome.json @@ -0,0 +1,25 @@ +{ + "manifest_version": 3, + "name": "CopilotKit DevTools", + "version": "0.0.1", + "description": "DevTools panel for CopilotKit runtime inspection.", + "devtools_page": "devtools.html", + "background": { + "service_worker": "background.js" + }, + "permissions": ["storage"], + "host_permissions": ["file:///*", "http://*/*", "https://*/*"], + "content_scripts": [ + { + "matches": [""], + "js": ["content.js"], + "run_at": "document_start" + }, + { + "matches": [""], + "js": ["page.js"], + "run_at": "document_start", + "world": "MAIN" + } + ] +} diff --git a/packages/devtools-extension/public/panel.html b/packages/devtools-extension/public/panel.html new file mode 100644 index 00000000..83a44d82 --- /dev/null +++ b/packages/devtools-extension/public/panel.html @@ -0,0 +1,22 @@ + + + + + CopilotKit Panel + + + +
+ + + diff --git a/packages/devtools-extension/scripts/build.mjs b/packages/devtools-extension/scripts/build.mjs new file mode 100644 index 00000000..a7edda04 --- /dev/null +++ b/packages/devtools-extension/scripts/build.mjs @@ -0,0 +1,68 @@ +import { build, context } from "esbuild"; +import { cp, mkdir, rm } from "fs/promises"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const rootDir = path.resolve(__dirname, ".."); +const distDir = path.join(rootDir, "dist"); +const isWatch = process.argv.includes("--watch"); +const isProd = process.env.NODE_ENV === "production"; + +const entryPoints = { + background: path.join(rootDir, "src/background.ts"), + content: path.join(rootDir, "src/content.ts"), + page: path.join(rootDir, "src/page.ts"), + devtools: path.join(rootDir, "src/devtools.ts"), + panel: path.join(rootDir, "src/panel.ts"), +}; + +const buildOptions = { + entryPoints, + outdir: distDir, + bundle: true, + sourcemap: !isProd, + target: "chrome114", + format: "iife", + platform: "browser", + logLevel: "info", + define: { + "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? "development"), + }, +}; + +async function copyPublicAssets() { + const publicDir = path.join(rootDir, "public"); + await mkdir(distDir, { recursive: true }); + await cp(publicDir, distDir, { recursive: true }); + + // Chrome expects manifest.json. We keep a browser-specific name in source, then rename on copy. + const chromeManifestSrc = path.join(distDir, "manifest.chrome.json"); + const chromeManifestDest = path.join(distDir, "manifest.json"); + try { + await cp(chromeManifestSrc, chromeManifestDest, { recursive: false, force: true }); + } catch (error) { + console.warn("[devtools-extension] Unable to copy manifest:", error); + } +} + +async function buildOnce() { + await rm(distDir, { recursive: true, force: true }); + await build(buildOptions); + await copyPublicAssets(); +} + +async function buildWatch() { + const ctx = await context(buildOptions); + await ctx.watch(); + await copyPublicAssets(); + console.log("[devtools-extension] Watching for changes..."); +} + +if (isWatch) { + await buildWatch(); +} else { + await buildOnce(); +} +/* eslint-env node */ +/* global process, console */ diff --git a/packages/devtools-extension/src/background.ts b/packages/devtools-extension/src/background.ts new file mode 100644 index 00000000..5f2a376d --- /dev/null +++ b/packages/devtools-extension/src/background.ts @@ -0,0 +1,242 @@ +import { CopilotKitCoreRuntimeConnectionStatus } from "@copilotkitnext/core"; +import type { + AgentsPayload, + ContextPayload, + EventsPatchPayload, + InitInstancePayload, + InspectorEvent, + RuntimeStatusPayload, + ToolsPayload, +} from "@copilotkitnext/devtools-inspector"; +import { chunkEnvelope, reassembleEnvelope } from "./shared/chunking"; +import { + EXTENSION_SOURCE, + MAX_AGENT_EVENTS, + MAX_TOTAL_EVENTS, + PAGE_SOURCE, + type Envelope, + type MessageType, +} from "./shared/protocol"; + +type TabCache = { + status?: RuntimeStatusPayload; + agents?: AgentsPayload; + tools?: ToolsPayload; + context?: ContextPayload; + events: InspectorEvent[]; +}; + +const tabPorts = new Map(); +const monitorPorts = new Map>(); +const tabChunkBuffers = new Map>(); +const monitorChunkBuffers = new Map>(); +const caches = new Map(); + +const getCache = (tabId: number): TabCache => { + if (!caches.has(tabId)) { + caches.set(tabId, { events: [] }); + } + return caches.get(tabId)!; +}; + +const enforceEventCaps = (events: InspectorEvent[]): InspectorEvent[] => { + const capped: InspectorEvent[] = []; + const perAgentCounts = new Map(); + + for (const event of events) { + const currentCount = perAgentCounts.get(event.agentId) ?? 0; + if (currentCount >= MAX_AGENT_EVENTS) { + continue; + } + perAgentCounts.set(event.agentId, currentCount + 1); + capped.push(event); + if (capped.length >= MAX_TOTAL_EVENTS) { + break; + } + } + + return capped; +}; + +const applyEventsPatch = (tabId: number, payload: EventsPatchPayload): EventsPatchPayload => { + const cache = getCache(tabId); + const merged = [...(payload.events ?? []), ...(cache.events ?? [])]; + cache.events = enforceEventCaps(merged); + return { events: payload.events ?? [] }; +}; + +const sendToPort = (port: chrome.runtime.Port, envelope: Envelope): void => { + const chunks = chunkEnvelope(envelope); + chunks.forEach((chunk) => port.postMessage(chunk)); +}; + +const broadcastToMonitors = (tabId: number, envelope: Envelope): void => { + const ports = monitorPorts.get(tabId); + if (!ports?.size) { + return; + } + for (const port of ports) { + sendToPort(port, envelope); + } +}; + +const replayCacheToMonitor = (tabId: number, port: chrome.runtime.Port): void => { + const cache = getCache(tabId); + const initPayload: InitInstancePayload = { + status: + cache.status ?? + ({ + runtimeStatus: CopilotKitCoreRuntimeConnectionStatus.Disconnected, + properties: {}, + } satisfies RuntimeStatusPayload), + agents: cache.agents?.agents ?? [], + tools: cache.tools?.tools ?? [], + context: cache.context?.context ?? {}, + events: cache.events ?? [], + }; + + sendToPort(port, { + source: EXTENSION_SOURCE, + type: "INIT_INSTANCE", + tabId, + payload: initPayload, + }); +}; + +const broadcastDisconnectedStatus = (tabId: number): void => { + const cache = getCache(tabId); + cache.status = { + runtimeStatus: CopilotKitCoreRuntimeConnectionStatus.Disconnected, + properties: cache.status?.properties ?? {}, + }; + + broadcastToMonitors(tabId, { + source: EXTENSION_SOURCE, + type: "STATUS", + tabId, + payload: cache.status, + }); +}; + +const handleTabMessage = (tabId: number, envelope: Envelope): void => { + const cache = getCache(tabId); + const { type } = envelope; + + switch (type as MessageType) { + case "INIT_INSTANCE": { + const payload = (envelope.payload ?? {}) as InitInstancePayload; + cache.status = payload.status ?? cache.status; + cache.agents = payload.agents ? { agents: payload.agents } : cache.agents; + cache.tools = payload.tools ? { tools: payload.tools } : cache.tools; + cache.context = payload.context ? { context: payload.context } : cache.context; + cache.events = enforceEventCaps(payload.events ?? []); + broadcastToMonitors(tabId, { ...envelope, source: EXTENSION_SOURCE, tabId }); + break; + } + case "STATUS": { + const payload = envelope.payload as RuntimeStatusPayload; + cache.status = payload; + broadcastToMonitors(tabId, { ...envelope, source: EXTENSION_SOURCE, tabId }); + break; + } + case "AGENTS": { + cache.agents = envelope.payload as AgentsPayload; + broadcastToMonitors(tabId, { ...envelope, source: EXTENSION_SOURCE, tabId }); + break; + } + case "TOOLS": { + cache.tools = envelope.payload as ToolsPayload; + broadcastToMonitors(tabId, { ...envelope, source: EXTENSION_SOURCE, tabId }); + break; + } + case "CONTEXT": { + cache.context = envelope.payload as ContextPayload; + broadcastToMonitors(tabId, { ...envelope, source: EXTENSION_SOURCE, tabId }); + break; + } + case "EVENTS_PATCH": { + const payload = applyEventsPatch(tabId, envelope.payload as EventsPatchPayload); + broadcastToMonitors(tabId, { ...envelope, payload, source: EXTENSION_SOURCE, tabId }); + break; + } + case "CLEAR": { + cache.events = []; + broadcastToMonitors(tabId, { ...envelope, source: EXTENSION_SOURCE, tabId }); + break; + } + case "ERROR": { + broadcastToMonitors(tabId, { ...envelope, source: EXTENSION_SOURCE, tabId }); + break; + } + default: + break; + } +}; + +chrome.runtime.onConnect.addListener((port) => { + if (port.name === "tab") { + const tabId = port.sender?.tab?.id; + if (typeof tabId !== "number") { + port.disconnect(); + return; + } + + tabPorts.set(tabId, port); + const buffer = new Map(); + tabChunkBuffers.set(tabId, buffer); + + port.onMessage.addListener((message) => { + const assembled = reassembleEnvelope(message as Envelope, buffer); + if (!assembled || assembled.source !== PAGE_SOURCE) { + return; + } + handleTabMessage(tabId, assembled); + }); + + port.onDisconnect.addListener(() => { + broadcastDisconnectedStatus(tabId); + tabPorts.delete(tabId); + tabChunkBuffers.delete(tabId); + }); + return; + } + + if (port.name.startsWith("monitor")) { + const tabId = Number(port.name.replace("monitor", "")); + if (!Number.isFinite(tabId)) { + port.disconnect(); + return; + } + + if (!monitorPorts.has(tabId)) { + monitorPorts.set(tabId, new Set()); + } + monitorPorts.get(tabId)!.add(port); + const buffer = new Map(); + monitorChunkBuffers.set(port, buffer); + + port.onMessage.addListener((message) => { + const assembled = reassembleEnvelope(message as Envelope, buffer); + if (!assembled) { + return; + } + + if (assembled.type === "REQUEST_INIT") { + replayCacheToMonitor(tabId, port); + return; + } + + if (assembled.type === "PANEL_READY") { + replayCacheToMonitor(tabId, port); + } + }); + + port.onDisconnect.addListener(() => { + monitorPorts.get(tabId)?.delete(port); + monitorChunkBuffers.delete(port); + if (monitorPorts.get(tabId)?.size === 0) { + monitorPorts.delete(tabId); + } + }); + } +}); diff --git a/packages/devtools-extension/src/content.ts b/packages/devtools-extension/src/content.ts new file mode 100644 index 00000000..f92fb172 --- /dev/null +++ b/packages/devtools-extension/src/content.ts @@ -0,0 +1,38 @@ +import { chunkEnvelope, reassembleEnvelope } from "./shared/chunking"; +import { EXTENSION_SOURCE, PAGE_SOURCE, type Envelope } from "./shared/protocol"; + +const port = chrome.runtime.connect({ name: "tab" }); +const inboundBuffer = new Map(); + +window.addEventListener("message", (event) => { + const data = event.data as Envelope | undefined; + if (!data || data.source !== PAGE_SOURCE || event.source !== window) { + return; + } + + const chunks = chunkEnvelope(data); + for (const chunk of chunks) { + port.postMessage(chunk); + } +}); + +port.onMessage.addListener((message) => { + const assembled = reassembleEnvelope(message as Envelope, inboundBuffer); + if (!assembled) { + return; + } + + const envelope: Envelope = { + ...assembled, + source: EXTENSION_SOURCE, + }; + + const chunks = chunkEnvelope(envelope); + for (const chunk of chunks) { + window.postMessage(chunk, "*"); + } +}); + +port.onDisconnect.addListener(() => { + inboundBuffer.clear(); +}); diff --git a/packages/devtools-extension/src/devtools.ts b/packages/devtools-extension/src/devtools.ts new file mode 100644 index 00000000..981d3b34 --- /dev/null +++ b/packages/devtools-extension/src/devtools.ts @@ -0,0 +1 @@ +chrome.devtools.panels.create("CopilotKit", "", "panel.html"); diff --git a/packages/devtools-extension/src/page.ts b/packages/devtools-extension/src/page.ts new file mode 100644 index 00000000..8c2dacdf --- /dev/null +++ b/packages/devtools-extension/src/page.ts @@ -0,0 +1,433 @@ +import type { AbstractAgent, AgentSubscriber } from "@ag-ui/client"; +import { + CopilotKitCoreRuntimeConnectionStatus, + type CopilotKitCore, + type CopilotKitCoreSubscriber, +} from "@copilotkitnext/core"; +import type { FrontendTool } from "@copilotkitnext/core"; +import type { + AgentSnapshot, + AgentsPayload, + EventsPatchPayload, + InitInstancePayload, + InspectorEvent, + RuntimeStatusPayload, +} from "@copilotkitnext/devtools-inspector"; +import { chunkEnvelope } from "./shared/chunking"; +import { MAX_AGENT_EVENTS, MAX_TOTAL_EVENTS, PAGE_SOURCE, type Envelope } from "./shared/protocol"; +import { + extractTools, + normalizeAgentMessages, + normalizeContextStore, + normalizeEventPayload, + sanitizeForLogging, +} from "./shared/normalize"; + +class PageBridge { + private core: CopilotKitCore | null = null; + private coreUnsubscribe: (() => void) | null = null; + private attachTimer: number | null = null; + private agentSubscriptions: Map void> = new Map(); + private agentMessages: Map = new Map(); + private agentStates: Map = new Map(); + private agentEvents: Map = new Map(); + private flattenedEvents: InspectorEvent[] = []; + private eventCounter = 0; + private lastError: { code?: string; message: string } | null = null; + private toolsPoller: number | null = null; + private lastToolsSignature = ""; + + start(): void { + this.installGlobalHook(); + this.tryAttach(); + this.attachTimer = window.setInterval(() => { + if (!this.core) { + this.tryAttach(); + } + }, 1200); + } + + private tryAttach(): void { + if (this.core) { + return; + } + + const globalWindow = window as unknown as Record; + const candidates: Array = [ + globalWindow.__COPILOTKIT_CORE__, + (globalWindow.copilotkit as { core?: unknown } | undefined)?.core, + globalWindow.copilotkitCore, + (globalWindow.__COPILOTKIT_DEVTOOLS__ as { core?: unknown } | undefined)?.core, + ]; + + const found = candidates.find((candidate) => !!candidate && typeof candidate === "object") as CopilotKitCore | undefined; + if (found) { + this.attachToCore(found); + if (this.attachTimer !== null) { + window.clearInterval(this.attachTimer); + } + } + } + + private attachToCore(core: CopilotKitCore): void { + (window as GlobalWithDevtools).__COPILOTKIT_DEVTOOLS__!.core = core; + this.detachFromCore(); + this.core = core; + this.startToolsPolling(); + + const subscriber: CopilotKitCoreSubscriber = { + onRuntimeConnectionStatusChanged: ({ status }) => { + this.emitStatus({ runtimeStatus: status, properties: core.properties, lastError: this.lastError }); + }, + onPropertiesChanged: ({ properties }) => { + this.emitStatus({ runtimeStatus: core.runtimeConnectionStatus, properties, lastError: this.lastError }); + }, + onError: ({ code, error }) => { + this.lastError = { code, message: error.message }; + this.emitStatus({ runtimeStatus: core.runtimeConnectionStatus, properties: core.properties, lastError: this.lastError }); + this.postMessage("ERROR", { message: error.message, code }); + }, + onAgentsChanged: ({ agents }) => { + this.processAgentsChanged(agents); + }, + onContextChanged: ({ context }) => { + this.emitContext(context); + }, + }; + + this.coreUnsubscribe = core.subscribe(subscriber).unsubscribe; + this.processAgentsChanged(core.agents); + this.emitInitSnapshot(); + } + + private detachFromCore(): void { + if (this.coreUnsubscribe) { + this.coreUnsubscribe(); + } + this.core = null; + this.coreUnsubscribe = null; + this.stopToolsPolling(); + this.agentSubscriptions.forEach((unsubscribe) => unsubscribe()); + this.agentSubscriptions.clear(); + this.agentMessages.clear(); + this.agentStates.clear(); + this.agentEvents.clear(); + this.flattenedEvents = []; + this.eventCounter = 0; + this.lastError = null; + } + + private emitStatus(status?: RuntimeStatusPayload): void { + if (!this.core && !status) { + return; + } + + const payload: RuntimeStatusPayload = status ?? { + runtimeStatus: this.core?.runtimeConnectionStatus ?? CopilotKitCoreRuntimeConnectionStatus.Disconnected, + properties: this.core?.properties ?? {}, + lastError: this.lastError, + }; + + this.postMessage("STATUS", payload); + } + + private emitContext(context?: Readonly> | null): void { + if (!this.core && !context) { + return; + } + this.postMessage("CONTEXT", { context: normalizeContextStore(context ?? this.core?.context) }); + } + + private emitTools(): void { + if (!this.core) { + return; + } + const tools = extractTools( + (this.core as unknown as { tools?: FrontendTool>[] | undefined }).tools ?? [], + this.core.agents, + ); + const signature = JSON.stringify(sanitizeForLogging(tools)); + if (signature === this.lastToolsSignature) { + return; + } + this.lastToolsSignature = signature; + this.postMessage("TOOLS", { tools }); + } + + private startToolsPolling(): void { + this.stopToolsPolling(); + // Push an initial tools payload so the panel sees existing tools immediately. + this.emitTools(); + this.toolsPoller = window.setInterval(() => { + this.emitTools(); + }, 1000); + } + + private stopToolsPolling(): void { + if (this.toolsPoller !== null) { + window.clearInterval(this.toolsPoller); + this.toolsPoller = null; + } + this.lastToolsSignature = ""; + } + + private emitAgents(agentIds?: string[]): void { + const ids = agentIds ?? Array.from(this.agentSubscriptions.keys()); + const agents: AgentSnapshot[] = []; + + for (const agentId of ids) { + const agent = this.core?.agents?.[agentId]; + if (!agent) continue; + agents.push({ + agentId, + toolHandlers: (agent as { toolHandlers?: Record }).toolHandlers, + toolRenderers: (agent as { toolRenderers?: Record }).toolRenderers, + state: this.agentStates.get(agentId), + messages: this.agentMessages.get(agentId), + }); + } + + const payload: AgentsPayload = { agents }; + + this.postMessage("AGENTS", payload); + } + + private emitInitSnapshot(): void { + if (!this.core) { + return; + } + + const payload: InitInstancePayload = { + status: { + runtimeStatus: this.core.runtimeConnectionStatus, + properties: this.core.properties, + lastError: this.lastError, + }, + context: normalizeContextStore(this.core.context), + tools: extractTools( + (this.core as unknown as { tools?: FrontendTool>[] | undefined }).tools ?? [], + this.core.agents, + ), + agents: Array.from(this.agentSubscriptions.keys()).flatMap((agentId) => { + const agent = this.core!.agents?.[agentId]; + if (!agent) return []; + return [ + { + agentId, + toolHandlers: (agent as { toolHandlers?: Record } | undefined)?.toolHandlers, + toolRenderers: (agent as { toolRenderers?: Record } | undefined)?.toolRenderers, + state: this.agentStates.get(agentId), + messages: this.agentMessages.get(agentId), + }, + ]; + }), + events: this.flattenedEvents, + }; + + this.postMessage("INIT_INSTANCE", payload); + } + + private processAgentsChanged(agents: Readonly>): void { + const seen = new Set(); + + for (const agent of Object.values(agents)) { + if (!agent?.agentId) { + continue; + } + seen.add(agent.agentId); + this.subscribeToAgent(agent); + } + + for (const agentId of Array.from(this.agentSubscriptions.keys())) { + if (!seen.has(agentId)) { + this.unsubscribeFromAgent(agentId); + this.agentStates.delete(agentId); + this.agentMessages.delete(agentId); + this.agentEvents.delete(agentId); + } + } + + this.emitAgents(); + this.emitTools(); + } + + private subscribeToAgent(agent: AbstractAgent): void { + if (!agent?.agentId) { + return; + } + + const agentId = agent.agentId; + this.unsubscribeFromAgent(agentId); + + const subscriber: AgentSubscriber = { + onRunStartedEvent: ({ event }) => this.recordAgentEvent(agentId, "RUN_STARTED", event), + onRunFinishedEvent: ({ event, result }) => this.recordAgentEvent(agentId, "RUN_FINISHED", { event, result }), + onRunErrorEvent: ({ event }) => this.recordAgentEvent(agentId, "RUN_ERROR", event), + onTextMessageStartEvent: ({ event }) => this.recordAgentEvent(agentId, "TEXT_MESSAGE_START", event), + onTextMessageContentEvent: ({ event, textMessageBuffer }) => + this.recordAgentEvent(agentId, "TEXT_MESSAGE_CONTENT", { event, textMessageBuffer }), + onTextMessageEndEvent: ({ event, textMessageBuffer }) => + this.recordAgentEvent(agentId, "TEXT_MESSAGE_END", { event, textMessageBuffer }), + onToolCallStartEvent: ({ event }) => this.recordAgentEvent(agentId, "TOOL_CALL_START", event), + onToolCallArgsEvent: ({ event, toolCallBuffer, toolCallName, partialToolCallArgs }) => + this.recordAgentEvent(agentId, "TOOL_CALL_ARGS", { event, toolCallBuffer, toolCallName, partialToolCallArgs }), + onToolCallEndEvent: ({ event, toolCallArgs, toolCallName }) => + this.recordAgentEvent(agentId, "TOOL_CALL_END", { event, toolCallArgs, toolCallName }), + onToolCallResultEvent: ({ event }) => this.recordAgentEvent(agentId, "TOOL_CALL_RESULT", event), + onStateSnapshotEvent: ({ event }) => { + this.recordAgentEvent(agentId, "STATE_SNAPSHOT", event); + this.syncAgentState(agent); + }, + onStateDeltaEvent: ({ event }) => { + this.recordAgentEvent(agentId, "STATE_DELTA", event); + this.syncAgentState(agent); + }, + onMessagesSnapshotEvent: ({ event }) => { + this.recordAgentEvent(agentId, "MESSAGES_SNAPSHOT", event); + this.syncAgentMessages(agent); + }, + onMessagesChanged: () => this.syncAgentMessages(agent), + onRawEvent: ({ event }) => this.recordAgentEvent(agentId, "RAW_EVENT", event), + onCustomEvent: ({ event }) => this.recordAgentEvent(agentId, "CUSTOM_EVENT", event), + }; + + const { unsubscribe } = agent.subscribe(subscriber); + this.agentSubscriptions.set(agentId, unsubscribe); + this.syncAgentMessages(agent); + this.syncAgentState(agent); + + if (!this.agentEvents.has(agentId)) { + this.agentEvents.set(agentId, []); + } + } + + private unsubscribeFromAgent(agentId: string): void { + const unsubscribe = this.agentSubscriptions.get(agentId); + if (unsubscribe) { + unsubscribe(); + this.agentSubscriptions.delete(agentId); + } + } + + private recordAgentEvent(agentId: string, type: InspectorEvent["type"], payload: unknown): void { + const eventId = `${agentId}:${++this.eventCounter}`; + const normalizedPayload = normalizeEventPayload(type, payload); + const event: InspectorEvent = { + id: eventId, + agentId, + type, + timestamp: Date.now(), + payload: normalizedPayload, + }; + + const currentEvents = this.agentEvents.get(agentId) ?? []; + const nextAgentEvents = [event, ...currentEvents].slice(0, MAX_AGENT_EVENTS); + this.agentEvents.set(agentId, nextAgentEvents); + + this.flattenedEvents = [event, ...this.flattenedEvents]; + if (this.flattenedEvents.length > MAX_TOTAL_EVENTS) { + const removed = this.flattenedEvents.splice(MAX_TOTAL_EVENTS); + for (const ev of removed) { + const perAgent = this.agentEvents.get(ev.agentId); + if (perAgent) { + this.agentEvents.set(ev.agentId, perAgent.filter((item) => item.id !== ev.id)); + } + } + } + + this.emitEventsPatch([event]); + + // Keep messages in sync for message-related events so the agents view stays populated. + if (this.isMessageEvent(type)) { + const agent = this.core?.agents?.[agentId]; + if (agent) { + this.syncAgentMessages(agent); + } + } + } + + private emitEventsPatch(events: InspectorEvent[]): void { + const payload: EventsPatchPayload = { events }; + this.postMessage("EVENTS_PATCH", payload); + } + + private isMessageEvent(type: InspectorEvent["type"]): boolean { + return ( + type === "TEXT_MESSAGE_START" || + type === "TEXT_MESSAGE_CONTENT" || + type === "TEXT_MESSAGE_END" || + type === "MESSAGES_SNAPSHOT" + ); + } + + private syncAgentMessages(agent: AbstractAgent): void { + if (!agent?.agentId) { + return; + } + + const messages = normalizeAgentMessages((agent as { messages?: unknown }).messages); + if (messages) { + this.agentMessages.set(agent.agentId, messages); + } else { + this.agentMessages.delete(agent.agentId); + } + + this.emitAgents([agent.agentId]); + } + + private syncAgentState(agent: AbstractAgent): void { + if (!agent?.agentId) { + return; + } + + const state = (agent as { state?: unknown }).state; + if (state === undefined || state === null) { + this.agentStates.delete(agent.agentId); + } else { + this.agentStates.set(agent.agentId, sanitizeForLogging(state)); + } + + this.emitAgents([agent.agentId]); + } + + private postMessage(type: Envelope["type"], payload?: Envelope["payload"]): void { + const envelope: Envelope = { + source: PAGE_SOURCE, + type, + payload, + }; + + const chunks = chunkEnvelope(envelope); + for (const chunk of chunks) { + window.postMessage(chunk, "*"); + } + } + + private installGlobalHook(): void { + const globalWindow = window as GlobalWithDevtools; + if (!globalWindow.__COPILOTKIT_DEVTOOLS__) { + const hook = { + core: undefined as CopilotKitCore | undefined, + setCore: (nextCore: CopilotKitCore) => { + hook.core = nextCore; + this.attachToCore(nextCore); + }, + }; + globalWindow.__COPILOTKIT_DEVTOOLS__ = hook; + } else if (globalWindow.__COPILOTKIT_DEVTOOLS__?.core) { + this.attachToCore(globalWindow.__COPILOTKIT_DEVTOOLS__.core); + } + } +} + +(() => { + const bridge = new PageBridge(); + bridge.start(); +})(); + +type GlobalWithDevtools = Window & { + __COPILOTKIT_DEVTOOLS__?: { + core?: CopilotKitCore; + setCore: (core: CopilotKitCore) => void; + }; +}; diff --git a/packages/devtools-extension/src/panel.ts b/packages/devtools-extension/src/panel.ts new file mode 100644 index 00000000..d7153728 --- /dev/null +++ b/packages/devtools-extension/src/panel.ts @@ -0,0 +1,77 @@ +import { + DEVTOOLS_INSPECTOR_HOST_TAG, + defineDevtoolsInspectorHost, + type AgentsPayload, + type ContextPayload, + type EventsPatchPayload, + type InitInstancePayload, + type RuntimeStatusPayload, + type ToolsPayload, +} from "@copilotkitnext/devtools-inspector"; +import { chunkEnvelope, reassembleEnvelope } from "./shared/chunking"; +import { EXTENSION_SOURCE, type Envelope } from "./shared/protocol"; + +type HostElement = HTMLElement & { + updateFromInit?: (payload: InitInstancePayload) => void; + updateFromStatus?: (payload: RuntimeStatusPayload) => void; + updateFromAgents?: (payload: AgentsPayload) => void; + updateFromTools?: (payload: ToolsPayload) => void; + updateFromContext?: (payload: ContextPayload) => void; + updateFromEvents?: (payload: EventsPatchPayload) => void; +}; + +defineDevtoolsInspectorHost(); + +const tabId = chrome.devtools.inspectedWindow.tabId; +const port = chrome.runtime.connect({ name: `monitor${tabId}` }); +const buffer = new Map(); + +const container = document.getElementById("app") ?? document.body; +const host = document.createElement(DEVTOOLS_INSPECTOR_HOST_TAG) as HostElement; +container.appendChild(host); + +const send = (type: Envelope["type"]): void => { + const envelope: Envelope = { source: EXTENSION_SOURCE, type, tabId }; + chunkEnvelope(envelope).forEach((chunk) => port.postMessage(chunk)); +}; + +const handleEnvelope = (envelope: Envelope): void => { + switch (envelope.type) { + case "INIT_INSTANCE": + host.updateFromInit?.((envelope.payload ?? {}) as InitInstancePayload); + break; + case "STATUS": + host.updateFromStatus?.(envelope.payload as RuntimeStatusPayload); + break; + case "AGENTS": + host.updateFromAgents?.(envelope.payload as AgentsPayload); + break; + case "TOOLS": + host.updateFromTools?.(envelope.payload as ToolsPayload); + break; + case "CONTEXT": + host.updateFromContext?.(envelope.payload as ContextPayload); + break; + case "EVENTS_PATCH": + host.updateFromEvents?.(envelope.payload as EventsPatchPayload); + break; + default: + break; + } +}; + +port.onMessage.addListener((message) => { + const assembled = reassembleEnvelope(message as Envelope, buffer); + if (!assembled) { + return; + } + + handleEnvelope(assembled); +}); + +port.onDisconnect.addListener(() => { + buffer.clear(); +}); + +send("REQUEST_INIT"); +send("PANEL_READY"); diff --git a/packages/devtools-extension/src/shared/chunking.ts b/packages/devtools-extension/src/shared/chunking.ts new file mode 100644 index 00000000..6e9e8f6d --- /dev/null +++ b/packages/devtools-extension/src/shared/chunking.ts @@ -0,0 +1,68 @@ +import type { Envelope } from "./protocol"; +import { MAX_PAYLOAD_SIZE, PAYLOAD_CHUNK_SIZE } from "./protocol"; + +type ChunkBuffer = { + header: Envelope; + chunks: string[]; +}; + +const randomId = (): string => { + const maybeCrypto = typeof globalThis !== "undefined" ? (globalThis as { crypto?: { randomUUID?: () => string } }).crypto : null; + if (maybeCrypto?.randomUUID) { + return maybeCrypto.randomUUID(); + } + return `chunk-${Date.now()}-${Math.random().toString(16).slice(2)}`; +}; + +export function chunkEnvelope(envelope: Envelope): Envelope[] { + if (envelope.payload === undefined || envelope.payload === null) { + return [envelope]; + } + + const serialized = JSON.stringify(envelope.payload); + if (serialized.length <= MAX_PAYLOAD_SIZE) { + return [envelope]; + } + + const id = envelope.id ?? randomId(); + const parts: Envelope[] = []; + for (let offset = 0; offset < serialized.length; offset += PAYLOAD_CHUNK_SIZE) { + const slice = serialized.slice(offset, offset + PAYLOAD_CHUNK_SIZE); + const split = offset === 0 ? "start" : offset + PAYLOAD_CHUNK_SIZE >= serialized.length ? "end" : "chunk"; + parts.push({ + ...envelope, + payload: undefined, + payloadChunk: slice, + split, + id, + }); + } + + return parts; +} + +export function reassembleEnvelope(envelope: Envelope, buffer: Map): Envelope | null { + if (!envelope.split) { + return envelope; + } + + const id = envelope.id ?? "unknown"; + const existing = buffer.get(id) ?? { header: { ...envelope, payload: undefined, payloadChunk: undefined }, chunks: [] }; + existing.header = { ...existing.header, ...envelope, payload: undefined, payloadChunk: undefined, split: undefined }; + existing.chunks.push(envelope.payloadChunk ?? ""); + + if (envelope.split === "end") { + const payloadString = existing.chunks.join(""); + buffer.delete(id); + try { + const parsed = payloadString ? JSON.parse(payloadString) : undefined; + return { ...existing.header, payload: parsed }; + } catch (error) { + console.warn("[copilotkit-extension] Failed to parse chunked payload", error); + return null; + } + } + + buffer.set(id, existing); + return null; +} diff --git a/packages/devtools-extension/src/shared/normalize.ts b/packages/devtools-extension/src/shared/normalize.ts new file mode 100644 index 00000000..b4ed16bb --- /dev/null +++ b/packages/devtools-extension/src/shared/normalize.ts @@ -0,0 +1,270 @@ +import type { AbstractAgent } from "@ag-ui/client"; +import type { + AgentEventType, + InspectorMessage, + InspectorToolCall, + InspectorToolDefinition, + SanitizedValue, +} from "@copilotkitnext/devtools-inspector"; +import type { FrontendTool } from "@copilotkitnext/core"; + +export function sanitizeForLogging(value: unknown, depth = 0, seen = new WeakSet()): SanitizedValue { + if (value === undefined) { + return "[undefined]"; + } + + if (value === null || typeof value === "number" || typeof value === "boolean" || typeof value === "string") { + return value as SanitizedValue; + } + + if (typeof value === "bigint" || typeof value === "symbol" || typeof value === "function") { + return String(value); + } + + if (value instanceof Date) { + return value.toISOString(); + } + + if (Array.isArray(value)) { + if (depth >= 4) { + return "[Truncated depth]"; + } + return value.map((item) => sanitizeForLogging(item, depth + 1, seen)); + } + + if (typeof value === "object") { + if (seen.has(value as object)) { + return "[Circular]"; + } + seen.add(value as object); + + if (depth >= 4) { + return "[Truncated depth]"; + } + + const result: Record = {}; + for (const [key, entry] of Object.entries(value as Record)) { + result[key] = sanitizeForLogging(entry, depth + 1, seen); + } + return result; + } + + return String(value); +} + +export function normalizeEventPayload(_type: AgentEventType | string, payload: unknown): SanitizedValue { + if (payload && typeof payload === "object" && "event" in (payload as Record)) { + const { event, ...rest } = payload as Record; + const cleaned = Object.keys(rest).length === 0 ? event : { event, ...rest }; + return sanitizeForLogging(cleaned); + } + + return sanitizeForLogging(payload); +} + +function normalizeMessageContent(content: unknown): string { + if (typeof content === "string") { + return content; + } + + if (Array.isArray(content)) { + const parts = content + .map((entry) => { + if (typeof entry === "string") { + return entry; + } + if (entry && typeof entry === "object") { + const record = entry as Record; + if (typeof record.text === "string") { + return record.text; + } + if (typeof record.content === "string") { + return record.content; + } + if (record.type === "text" && typeof record.value === "string") { + return record.value; + } + } + return ""; + }) + .filter(Boolean); + + if (parts.length) { + return parts.join("\n\n"); + } + } + + if (content && typeof content === "object" && "text" in (content as Record)) { + const maybeText = (content as Record).text; + if (typeof maybeText === "string") { + return maybeText; + } + } + + if (content === null || content === undefined) { + return ""; + } + + if (typeof content === "object") { + try { + return JSON.stringify(sanitizeForLogging(content)); + } catch { + return ""; + } + } + + return String(content); +} + +function normalizeToolCalls(raw: unknown): InspectorToolCall[] { + if (!Array.isArray(raw)) { + return []; + } + + return raw + .map((entry) => { + if (!entry || typeof entry !== "object") { + return null; + } + const call = entry as Record; + const fn = call.function as Record | undefined; + const functionName = + typeof fn?.name === "string" ? fn.name : typeof call.toolName === "string" ? call.toolName : undefined; + const args = fn && "arguments" in fn ? (fn as Record).arguments : call.arguments; + + const normalized: InspectorToolCall = { + id: typeof call.id === "string" ? call.id : undefined, + toolName: typeof call.toolName === "string" ? call.toolName : functionName, + status: typeof call.status === "string" ? call.status : undefined, + }; + + if (functionName) { + normalized.function = { + name: functionName, + arguments: sanitizeForLogging(args), + }; + } + + return normalized; + }) + .filter((call): call is InspectorToolCall => Boolean(call)); +} + +function normalizeAgentMessage(message: unknown): InspectorMessage | null { + if (!message || typeof message !== "object") { + return null; + } + + const raw = message as Record; + const role = typeof raw.role === "string" ? raw.role : "unknown"; + const contentText = normalizeMessageContent(raw.content); + const toolCalls = normalizeToolCalls(raw.toolCalls); + + return { + id: typeof raw.id === "string" ? raw.id : undefined, + role, + contentText, + contentRaw: raw.content !== undefined ? sanitizeForLogging(raw.content) : undefined, + toolCalls, + }; +} + +export function normalizeAgentMessages(messages: unknown): InspectorMessage[] | null { + if (!Array.isArray(messages)) { + return null; + } + + const normalized = messages + .map((message) => normalizeAgentMessage(message)) + .filter((msg): msg is InspectorMessage => msg !== null); + + return normalized; +} + +export function normalizeContextStore( + context: Readonly> | null | undefined, +): Record { + if (!context || typeof context !== "object") { + return {}; + } + + const normalized: Record = {}; + for (const [key, entry] of Object.entries(context)) { + if (entry && typeof entry === "object" && "value" in (entry as Record)) { + const candidate = entry as Record; + const description = + typeof candidate.description === "string" && candidate.description.trim().length > 0 + ? candidate.description + : undefined; + normalized[key] = { description, value: candidate.value }; + } else { + normalized[key] = { value: entry }; + } + } + + return normalized; +} + +export function extractTools( + coreTools: ReadonlyArray>> | undefined, + agents: Readonly>, +): InspectorToolDefinition[] { + const tools: InspectorToolDefinition[] = []; + + for (const coreTool of coreTools ?? []) { + tools.push({ + agentId: (coreTool.agentId as string) ?? "", + name: coreTool.name, + description: coreTool.description, + parameters: coreTool.parameters, + type: "handler", + }); + } + + for (const [agentId, agent] of Object.entries(agents)) { + if (!agent) continue; + + const handlers = (agent as { toolHandlers?: Record }).toolHandlers; + if (handlers && typeof handlers === "object") { + for (const [toolName, handler] of Object.entries(handlers)) { + if (handler && typeof handler === "object") { + const handlerObj = handler as Record; + tools.push({ + agentId, + name: toolName, + description: + (typeof handlerObj.description === "string" && handlerObj.description) || + (handlerObj.tool as { description?: string } | undefined)?.description, + parameters: + handlerObj.parameters ?? (handlerObj.tool as { parameters?: unknown } | undefined)?.parameters, + type: "handler", + }); + } + } + } + + const renderers = (agent as { toolRenderers?: Record }).toolRenderers; + if (renderers && typeof renderers === "object") { + for (const [toolName, renderer] of Object.entries(renderers)) { + if (tools.some((tool) => tool.agentId === agentId && tool.name === toolName)) { + continue; + } + if (renderer && typeof renderer === "object") { + const rendererObj = renderer as Record; + tools.push({ + agentId, + name: toolName, + description: + (typeof rendererObj.description === "string" && rendererObj.description) || + (rendererObj.tool as { description?: string } | undefined)?.description, + parameters: + rendererObj.parameters ?? (rendererObj.tool as { parameters?: unknown } | undefined)?.parameters, + type: "renderer", + }); + } + } + } + } + + return tools; +} diff --git a/packages/devtools-extension/src/shared/protocol.ts b/packages/devtools-extension/src/shared/protocol.ts new file mode 100644 index 00000000..325e9ac4 --- /dev/null +++ b/packages/devtools-extension/src/shared/protocol.ts @@ -0,0 +1,47 @@ +import type { + AgentsPayload, + ContextPayload, + EventsPatchPayload, + InitInstancePayload, + RuntimeStatusPayload, + ToolsPayload, +} from "@copilotkitnext/devtools-inspector"; + +export const PAGE_SOURCE = "@copilotkit-page" as const; +export const EXTENSION_SOURCE = "@copilotkit-extension" as const; + +export type MessageType = + | "INIT_INSTANCE" + | "STATUS" + | "AGENTS" + | "TOOLS" + | "CONTEXT" + | "EVENTS_PATCH" + | "CLEAR" + | "ERROR" + | "REQUEST_INIT" + | "PANEL_READY"; + +export type Envelope = { + source: typeof PAGE_SOURCE | typeof EXTENSION_SOURCE; + type: MessageType; + payload?: T; + payloadChunk?: string; + split?: "start" | "chunk" | "end"; + id?: string; + tabId?: number; +}; + +export type ExtensionPayload = + | InitInstancePayload + | RuntimeStatusPayload + | AgentsPayload + | ToolsPayload + | ContextPayload + | EventsPatchPayload + | { message?: string }; + +export const MAX_AGENT_EVENTS = 200; +export const MAX_TOTAL_EVENTS = 500; +export const MAX_PAYLOAD_SIZE = 10 * 1024 * 1024; +export const PAYLOAD_CHUNK_SIZE = 5 * 1024 * 1024; diff --git a/packages/devtools-extension/tsconfig.json b/packages/devtools-extension/tsconfig.json new file mode 100644 index 00000000..9fa1ee7f --- /dev/null +++ b/packages/devtools-extension/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@copilotkitnext/typescript-config/base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "declaration": false, + "declarationMap": false, + "sourceMap": true + }, + "include": ["src/**/*", "scripts/**/*"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/devtools-inspector/README.md b/packages/devtools-inspector/README.md new file mode 100644 index 00000000..204e0fc7 --- /dev/null +++ b/packages/devtools-inspector/README.md @@ -0,0 +1,8 @@ +# DevTools inspector host + +Lit wrapper that mounts the existing `` element and exposes helpers for the DevTools panel. + +- Import `defineDevtoolsInspectorHost` to register ``. +- Feed it with extension payloads via `updateFromInit`, `updateFromStatus`, `updateFromAgents`, `updateFromTools`, `updateFromContext`, and `updateFromEvents`. + +This package keeps the original `@copilotkitnext/web-inspector` untouched and only provides a thin remote core/agent shim for the DevTools experience. diff --git a/packages/devtools-inspector/eslint.config.mjs b/packages/devtools-inspector/eslint.config.mjs new file mode 100644 index 00000000..daa75ba7 --- /dev/null +++ b/packages/devtools-inspector/eslint.config.mjs @@ -0,0 +1,3 @@ +import { config as baseConfig } from "@copilotkitnext/eslint-config/base"; + +export default [...baseConfig]; diff --git a/packages/devtools-inspector/package.json b/packages/devtools-inspector/package.json new file mode 100644 index 00000000..8266f137 --- /dev/null +++ b/packages/devtools-inspector/package.json @@ -0,0 +1,39 @@ +{ + "name": "@copilotkitnext/devtools-inspector", + "version": "0.0.1", + "description": "POC host for the CopilotKit DevTools inspector panel", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + } + }, + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "lint": "eslint . --max-warnings 0", + "check-types": "tsc --noEmit", + "clean": "rm -rf dist", + "prepublishOnly": "pnpm run build" + }, + "dependencies": { + "@ag-ui/client": "0.0.42-alpha.1", + "@copilotkitnext/core": "workspace:*", + "@copilotkitnext/web-inspector": "workspace:*", + "lit": "^3.2.0" + }, + "devDependencies": { + "@copilotkitnext/eslint-config": "workspace:*", + "@copilotkitnext/typescript-config": "workspace:*", + "@types/chrome": "^0.0.300", + "eslint": "^9.30.0", + "tsup": "^8.5.0", + "typescript": "5.8.2" + }, + "engines": { + "node": ">=18" + } +} diff --git a/packages/devtools-inspector/src/index.ts b/packages/devtools-inspector/src/index.ts new file mode 100644 index 00000000..e1d40eb8 --- /dev/null +++ b/packages/devtools-inspector/src/index.ts @@ -0,0 +1,196 @@ +import { LitElement, css, html } from "lit"; +import { customElement } from "lit/decorators.js"; +import { defineWebInspector } from "@copilotkitnext/web-inspector"; +import type { WebInspectorElement } from "@copilotkitnext/web-inspector"; +import { RemoteCopilotCore } from "./remote-core"; +import type { + AgentsPayload, + ContextPayload, + EventsPatchPayload, + InitInstancePayload, + RuntimeStatusPayload, + ToolsPayload, +} from "./types"; + +defineWebInspector(); + +export const DEVTOOLS_INSPECTOR_HOST_TAG = "devtools-inspector-host" as const; + +@customElement(DEVTOOLS_INSPECTOR_HOST_TAG) +export class DevtoolsInspectorHost extends LitElement { + static styles = css` + :host { + display: block; + height: 100%; + width: 100%; + } + + .panel { + position: relative; + height: 100%; + width: 100%; + background: #f8fafc; + color: #0f172a; + font-family: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + } + + .empty { + align-items: center; + color: #475569; + display: flex; + font-size: 13px; + height: 100%; + justify-content: center; + letter-spacing: 0.01em; + text-align: center; + padding: 16px; + } + + .inspector { + height: 100%; + width: 100%; + } + `; + + private readonly core = new RemoteCopilotCore(); + private hasData = false; + private resizeObserver: ResizeObserver | null = null; + + updateFromInit(payload: InitInstancePayload): void { + this.hasData = true; + this.core.reset(payload); + this.expandInspector(); + this.requestUpdate(); + } + + updateFromStatus(payload: RuntimeStatusPayload): void { + this.hasData = true; + this.core.updateStatus(payload); + this.expandInspector(); + this.requestUpdate(); + } + + updateFromAgents(payload: AgentsPayload): void { + this.hasData = true; + this.core.applyAgents(payload); + this.expandInspector(); + this.requestUpdate(); + } + + updateFromTools(payload: ToolsPayload): void { + this.hasData = true; + this.core.updateTools(payload); + this.expandInspector(); + this.requestUpdate(); + } + + updateFromContext(payload: ContextPayload): void { + this.hasData = true; + this.core.updateContext(payload); + this.expandInspector(); + this.requestUpdate(); + } + + updateFromEvents(payload: EventsPatchPayload): void { + this.hasData = true; + this.core.applyEvents(payload); + this.expandInspector(); + } + + connectedCallback(): void { + super.connectedCallback(); + this.resizeObserver = new ResizeObserver(() => this.applyFullPanelLayout()); + this.resizeObserver.observe(this); + } + + disconnectedCallback(): void { + this.resizeObserver?.disconnect(); + this.resizeObserver = null; + super.disconnectedCallback(); + } + + render() { + return html` +
+ ${this.hasData + ? html`` + : html`
No CopilotKit runtime found in this tab yet.
`} +
+ `; + } + + private expandInspector(): void { + const inspector = this.getInspector(); + if (!inspector) { + return; + } + + // Force the window open and sized to the panel. + (inspector as unknown as { openInspector?: () => void }).openInspector?.(); + (inspector as unknown as { setDockMode?: (mode: string) => void }).setDockMode?.("floating"); + this.applyFullPanelLayout(); + } + + private applyFullPanelLayout(): void { + const inspector = this.getInspector(); + if (!inspector) { + return; + } + + const width = this.clientWidth || this.offsetWidth || 1200; + const height = this.clientHeight || this.offsetHeight || 800; + + const windowContext = (inspector as unknown as { contextState?: Record> }).contextState + ?.window as + | { + size?: { width: number; height: number }; + position?: { x: number; y: number }; + anchor?: { horizontal: string; vertical: string }; + anchorOffset?: { x: number; y: number }; + } + | undefined; + if (windowContext) { + windowContext.size = { width, height }; + windowContext.position = { x: 0, y: 0 }; + windowContext.anchor = { horizontal: "left", vertical: "top" }; + windowContext.anchorOffset = { x: 0, y: 0 }; + const hasCustomPosition = (inspector as unknown as { hasCustomPosition?: Record }).hasCustomPosition; + if (hasCustomPosition) { + hasCustomPosition.window = true; + } + inspector.style.transform = "translate3d(0px, 0px, 0)"; + } + + const windowEl = inspector.shadowRoot?.querySelector(".inspector-window"); + if (windowEl) { + windowEl.style.width = "100%"; + windowEl.style.height = "100%"; + windowEl.style.maxWidth = "100%"; + windowEl.style.maxHeight = "100%"; + windowEl.style.borderRadius = "0"; + windowEl.style.boxShadow = "none"; + windowEl.style.border = "none"; + } + + inspector.requestUpdate?.(); + } + + private getInspector(): WebInspectorElement | null { + return this.renderRoot?.querySelector("web-inspector") as WebInspectorElement | null; + } +} + +export function defineDevtoolsInspectorHost(): void { + if (!customElements.get(DEVTOOLS_INSPECTOR_HOST_TAG)) { + customElements.define(DEVTOOLS_INSPECTOR_HOST_TAG, DevtoolsInspectorHost); + } +} + +declare global { + interface HTMLElementTagNameMap { + [DEVTOOLS_INSPECTOR_HOST_TAG]: DevtoolsInspectorHost; + } +} + +export { RemoteCopilotCore } from "./remote-core"; +export type * from "./types"; diff --git a/packages/devtools-inspector/src/remote-core.ts b/packages/devtools-inspector/src/remote-core.ts new file mode 100644 index 00000000..0373e53d --- /dev/null +++ b/packages/devtools-inspector/src/remote-core.ts @@ -0,0 +1,329 @@ +import { + CopilotKitCoreErrorCode, + CopilotKitCoreRuntimeConnectionStatus, + type CopilotKitCoreSubscriber, +} from "@copilotkitnext/core"; +import type { + AgentEventType, + AgentSnapshot, + AgentsPayload, + ContextPayload, + EventsPatchPayload, + InitInstancePayload, + InspectorEvent, + RuntimeStatusPayload, + ToolsPayload, +} from "./types"; + +type AgentSubscriberLike = { + onRunStartedEvent?: (params: { event: unknown }) => void; + onRunFinishedEvent?: (params: { event: unknown; result?: unknown }) => void; + onRunErrorEvent?: (params: { event: unknown }) => void; + onTextMessageStartEvent?: (params: { event: unknown }) => void; + onTextMessageContentEvent?: (params: { event: unknown; textMessageBuffer?: string }) => void; + onTextMessageEndEvent?: (params: { event: unknown; textMessageBuffer?: string }) => void; + onToolCallStartEvent?: (params: { event: unknown }) => void; + onToolCallArgsEvent?: (params: { + event: unknown; + toolCallBuffer?: string; + toolCallName?: string; + partialToolCallArgs?: Record; + }) => void; + onToolCallEndEvent?: (params: { event: unknown; toolCallArgs?: Record; toolCallName?: string }) => void; + onToolCallResultEvent?: (params: { event: unknown }) => void; + onStateSnapshotEvent?: (params: { event: unknown }) => void; + onStateDeltaEvent?: (params: { event: unknown }) => void; + onMessagesSnapshotEvent?: (params: { event: unknown }) => void; + onRawEvent?: (params: { event: unknown }) => void; + onCustomEvent?: (params: { event: unknown }) => void; + onEvent?: (params: { event: unknown }) => void; + onMessagesChanged?: (params?: unknown) => void; + onStateChanged?: (params?: unknown) => void; +}; + +const createDefaultParams = (agent: RemoteAgent) => ({ + agent, + messages: agent.messages ?? [], + state: agent.state ?? {}, + input: {}, +}); + +class RemoteAgent { + agentId: string; + toolHandlers?: Record; + toolRenderers?: Record; + state?: unknown; + messages?: unknown[]; + + private subscribers: Set = new Set(); + + constructor(agentId: string) { + this.agentId = agentId; + } + + updateSnapshot(snapshot: AgentSnapshot): void { + this.toolHandlers = snapshot.toolHandlers; + this.toolRenderers = snapshot.toolRenderers; + + if ("state" in snapshot) { + this.state = snapshot.state; + this.notifyStateChanged(); + } + + if (snapshot.messages) { + this.messages = snapshot.messages as unknown[]; + this.notifyMessagesChanged(); + } + } + + emitEvents(events: InspectorEvent[]): void { + for (const event of events) { + this.dispatchEvent(event); + } + } + + subscribe(subscriber: AgentSubscriberLike): { unsubscribe: () => void } { + this.subscribers.add(subscriber); + return { + unsubscribe: () => { + this.subscribers.delete(subscriber); + }, + }; + } + + private dispatchEvent(event: InspectorEvent): void { + const params = createDefaultParams(this); + const payload = event.payload as Record; + + for (const subscriber of this.subscribers) { + switch (event.type as AgentEventType) { + case "RUN_STARTED": + subscriber.onRunStartedEvent?.({ ...params, event: payload }); + break; + case "RUN_FINISHED": + subscriber.onRunFinishedEvent?.({ + ...params, + event: (payload as { event?: unknown }).event ?? payload, + result: (payload as { result?: unknown }).result, + }); + break; + case "RUN_ERROR": + subscriber.onRunErrorEvent?.({ ...params, event: payload }); + break; + case "TEXT_MESSAGE_START": + subscriber.onTextMessageStartEvent?.({ ...params, event: payload }); + break; + case "TEXT_MESSAGE_CONTENT": + subscriber.onTextMessageContentEvent?.({ + ...params, + event: payload, + textMessageBuffer: (payload as { textMessageBuffer?: string }).textMessageBuffer ?? "", + }); + break; + case "TEXT_MESSAGE_END": + subscriber.onTextMessageEndEvent?.({ + ...params, + event: payload, + textMessageBuffer: (payload as { textMessageBuffer?: string }).textMessageBuffer ?? "", + }); + break; + case "TOOL_CALL_START": + subscriber.onToolCallStartEvent?.({ ...params, event: payload }); + break; + case "TOOL_CALL_ARGS": + subscriber.onToolCallArgsEvent?.({ + ...params, + event: payload, + toolCallBuffer: (payload as { toolCallBuffer?: string }).toolCallBuffer ?? "", + toolCallName: (payload as { toolCallName?: string }).toolCallName ?? "", + partialToolCallArgs: (payload as { partialToolCallArgs?: Record }).partialToolCallArgs ?? {}, + }); + break; + case "TOOL_CALL_END": + subscriber.onToolCallEndEvent?.({ + ...params, + event: payload, + toolCallName: (payload as { toolCallName?: string }).toolCallName ?? "", + toolCallArgs: (payload as { toolCallArgs?: Record }).toolCallArgs ?? {}, + }); + break; + case "TOOL_CALL_RESULT": + subscriber.onToolCallResultEvent?.({ ...params, event: payload }); + break; + case "STATE_SNAPSHOT": + subscriber.onStateSnapshotEvent?.({ ...params, event: payload }); + this.notifyStateChanged(); + break; + case "STATE_DELTA": + subscriber.onStateDeltaEvent?.({ ...params, event: payload }); + this.notifyStateChanged(); + break; + case "MESSAGES_SNAPSHOT": + subscriber.onMessagesSnapshotEvent?.({ ...params, event: payload }); + this.notifyMessagesChanged(); + break; + case "RAW_EVENT": + subscriber.onRawEvent?.({ ...params, event: payload }); + break; + case "CUSTOM_EVENT": + subscriber.onCustomEvent?.({ ...params, event: payload }); + break; + default: + // Unknown event types are still surfaced to generic handlers if available. + subscriber.onEvent?.({ ...params, event: payload as unknown as { type: string } }); + break; + } + } + } + + private notifyMessagesChanged(): void { + const params = createDefaultParams(this); + for (const subscriber of this.subscribers) { + subscriber.onMessagesChanged?.(params); + } + } + + private notifyStateChanged(): void { + const params = createDefaultParams(this); + for (const subscriber of this.subscribers) { + subscriber.onStateChanged?.(params); + } + } +} + +export class RemoteCopilotCore +{ + runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus = CopilotKitCoreRuntimeConnectionStatus.Disconnected; + properties: Readonly> = {}; + context: Record = {}; + tools: unknown[] = []; + + agents: Record = {}; + + private subscribers: Set = new Set(); + private lastError: { code?: CopilotKitCoreErrorCode; message: string } | null = null; + + reset(payload: InitInstancePayload): void { + this.updateStatus(payload.status ?? { runtimeStatus: CopilotKitCoreRuntimeConnectionStatus.Disconnected, properties: {} }); + this.applyAgents({ agents: payload.agents ?? [] }); + this.updateTools({ tools: payload.tools ?? [] }); + this.updateContext({ context: payload.context ?? {} }); + + if (payload.events?.length) { + this.applyEvents({ events: payload.events }); + } + } + + updateStatus(payload: RuntimeStatusPayload): void { + this.runtimeConnectionStatus = payload.runtimeStatus ?? CopilotKitCoreRuntimeConnectionStatus.Disconnected; + this.properties = payload.properties ?? {}; + const incomingCode = payload.lastError?.code; + const normalizedCode = incomingCode && Object.values(CopilotKitCoreErrorCode).includes(incomingCode as CopilotKitCoreErrorCode) + ? (incomingCode as CopilotKitCoreErrorCode) + : undefined; + this.lastError = payload.lastError ? { code: normalizedCode, message: payload.lastError.message } : null; + + this.notify((subscriber) => + subscriber.onRuntimeConnectionStatusChanged?.({ + copilotkit: this as unknown as import("@copilotkitnext/core").CopilotKitCore, + status: this.runtimeConnectionStatus, + }), + ); + + this.notify((subscriber) => + subscriber.onPropertiesChanged?.({ + copilotkit: this as unknown as import("@copilotkitnext/core").CopilotKitCore, + properties: this.properties, + }), + ); + + if (this.lastError) { + const errCode = this.lastError.code ?? CopilotKitCoreErrorCode.AGENT_RUN_FAILED; + this.notify((subscriber) => + subscriber.onError?.({ + copilotkit: this as unknown as import("@copilotkitnext/core").CopilotKitCore, + error: new Error(this.lastError?.message), + code: errCode, + context: {}, + }), + ); + } + } + + updateTools(payload: ToolsPayload): void { + this.tools = payload.tools ?? []; + this.notifyAgentsChanged(); + } + + updateContext(payload: ContextPayload): void { + this.context = payload.context ?? {}; + this.notify((subscriber) => + subscriber.onContextChanged?.({ + copilotkit: this as unknown as import("@copilotkitnext/core").CopilotKitCore, + context: this.context as unknown as Readonly>, + }), + ); + } + + applyAgents(payload: AgentsPayload): void { + const incomingIds = new Set(); + for (const snapshot of payload.agents) { + if (!snapshot.agentId) continue; + incomingIds.add(snapshot.agentId); + const existing = this.agents[snapshot.agentId] ?? new RemoteAgent(snapshot.agentId); + existing.updateSnapshot(snapshot); + this.agents[snapshot.agentId] = existing; + } + + for (const agentId of Object.keys(this.agents)) { + if (!incomingIds.has(agentId)) { + delete this.agents[agentId]; + } + } + + this.notifyAgentsChanged(); + } + + applyEvents(payload: EventsPatchPayload): void { + if (!payload.events?.length) { + return; + } + + const grouped = new Map(); + for (const event of payload.events) { + if (!grouped.has(event.agentId)) { + grouped.set(event.agentId, []); + } + grouped.get(event.agentId)!.push(event); + } + + for (const [agentId, events] of grouped.entries()) { + if (!this.agents[agentId]) { + this.agents[agentId] = new RemoteAgent(agentId); + } + this.agents[agentId]!.emitEvents(events); + } + } + + subscribe(subscriber: CopilotKitCoreSubscriber): { unsubscribe: () => void } { + this.subscribers.add(subscriber); + return { + unsubscribe: () => this.subscribers.delete(subscriber), + }; + } + + private notify(handler: (subscriber: CopilotKitCoreSubscriber) => void): void { + for (const subscriber of this.subscribers) { + handler(subscriber); + } + } + + private notifyAgentsChanged(): void { + this.notify((subscriber) => + subscriber.onAgentsChanged?.({ + copilotkit: this as unknown as import("@copilotkitnext/core").CopilotKitCore, + agents: this.agents as unknown as Readonly>, + }), + ); + } +} diff --git a/packages/devtools-inspector/src/types.ts b/packages/devtools-inspector/src/types.ts new file mode 100644 index 00000000..bf173fc8 --- /dev/null +++ b/packages/devtools-inspector/src/types.ts @@ -0,0 +1,105 @@ +import type { CopilotKitCoreRuntimeConnectionStatus } from "@copilotkitnext/core"; + +export type SanitizedValue = + | string + | number + | boolean + | null + | SanitizedValue[] + | { + [key: string]: SanitizedValue; + }; + +export type AgentEventType = + | "RUN_STARTED" + | "RUN_FINISHED" + | "RUN_ERROR" + | "TEXT_MESSAGE_START" + | "TEXT_MESSAGE_CONTENT" + | "TEXT_MESSAGE_END" + | "TOOL_CALL_START" + | "TOOL_CALL_ARGS" + | "TOOL_CALL_END" + | "TOOL_CALL_RESULT" + | "STATE_SNAPSHOT" + | "STATE_DELTA" + | "MESSAGES_SNAPSHOT" + | "RAW_EVENT" + | "CUSTOM_EVENT"; + +export type InspectorToolCall = { + id?: string; + function?: { + name?: string; + arguments?: SanitizedValue | string; + }; + toolName?: string; + status?: string; +}; + +export type InspectorMessage = { + id?: string; + role: string; + contentText: string; + contentRaw?: SanitizedValue; + toolCalls: InspectorToolCall[]; +}; + +export type InspectorToolDefinition = { + agentId: string; + name: string; + description?: string; + parameters?: unknown; + type: "handler" | "renderer"; +}; + +export type InspectorEvent = { + id: string; + agentId: string; + type: AgentEventType | string; + timestamp: number; + payload: SanitizedValue; +}; + +export type ContextEntry = { + description?: string; + value: unknown; +}; + +export type AgentSnapshot = { + agentId: string; + toolHandlers?: Record; + toolRenderers?: Record; + state?: SanitizedValue | null; + messages?: InspectorMessage[]; +}; + +export type AgentsPayload = { + agents: AgentSnapshot[]; +}; + +export type RuntimeStatusPayload = { + runtimeStatus: CopilotKitCoreRuntimeConnectionStatus | null; + properties: Readonly>; + lastError?: { code?: string; message: string } | null; +}; + +export type ToolsPayload = { + tools: InspectorToolDefinition[]; +}; + +export type ContextPayload = { + context: Record; +}; + +export type EventsPatchPayload = { + events: InspectorEvent[]; +}; + +export type InitInstancePayload = { + status?: RuntimeStatusPayload; + agents?: AgentSnapshot[]; + tools?: InspectorToolDefinition[]; + context?: Record; + events?: InspectorEvent[]; +}; diff --git a/packages/devtools-inspector/tsconfig.json b/packages/devtools-inspector/tsconfig.json new file mode 100644 index 00000000..c3ccee0f --- /dev/null +++ b/packages/devtools-inspector/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@copilotkitnext/typescript-config/base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/devtools-inspector/tsup.config.ts b/packages/devtools-inspector/tsup.config.ts new file mode 100644 index 00000000..7cd09fa1 --- /dev/null +++ b/packages/devtools-inspector/tsup.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["cjs", "esm"], + dts: true, + sourcemap: true, + clean: true, + target: "es2022" +}); diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index e24b6d09..7aadd5a0 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,8 +1,7 @@ "use client"; -// Re-export AG-UI runtime and types to provide a single import surface -// This helps avoid version mismatches by letting apps import everything from '@copilotkitnext/react' -export * from "@ag-ui/core"; +// Re-export AG-UI client runtime and types from a single import surface. +// Avoid re-exporting @ag-ui/core to prevent star-export name collisions. export * from "@ag-ui/client"; // React components and hooks for CopilotKit2 diff --git a/packages/react/src/providers/CopilotKitDevtoolsProvider.tsx b/packages/react/src/providers/CopilotKitDevtoolsProvider.tsx new file mode 100644 index 00000000..c4035501 --- /dev/null +++ b/packages/react/src/providers/CopilotKitDevtoolsProvider.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { ReactNode, useEffect } from "react"; +import { useCopilotKit } from "./CopilotKitProvider"; + +type DevtoolsHook = { + core?: unknown; + setCore: (core: unknown) => void; +}; + +export interface CopilotKitDevtoolsProviderProps { + children?: ReactNode; +} + +export const CopilotKitDevtoolsProvider: React.FC = ({ children }) => { + const { copilotkit } = useCopilotKit(); + + useEffect(() => { + if (typeof window === "undefined") { + return; + } + + const globalWindow = window as typeof window & { __COPILOTKIT_DEVTOOLS__?: DevtoolsHook }; + const hook = + globalWindow.__COPILOTKIT_DEVTOOLS__ ?? + ({ + core: undefined, + setCore(nextCore: unknown) { + this.core = nextCore; + }, + } satisfies DevtoolsHook); + + hook.setCore(copilotkit); + globalWindow.__COPILOTKIT_DEVTOOLS__ = hook; + }, [copilotkit]); + + return <>{children}; +}; diff --git a/packages/react/src/providers/index.ts b/packages/react/src/providers/index.ts index ddae5826..a3421592 100644 --- a/packages/react/src/providers/index.ts +++ b/packages/react/src/providers/index.ts @@ -12,3 +12,8 @@ export { type CopilotKitProviderProps, type CopilotKitContextValue, } from "./CopilotKitProvider"; + +export { + CopilotKitDevtoolsProvider, + type CopilotKitDevtoolsProviderProps, +} from "./CopilotKitDevtoolsProvider"; diff --git a/packages/web-inspector/package.json b/packages/web-inspector/package.json index a78f44c9..7cd4f975 100644 --- a/packages/web-inspector/package.json +++ b/packages/web-inspector/package.json @@ -22,7 +22,8 @@ "prepublishOnly": "pnpm run build:css && pnpm run build", "lint": "eslint . --max-warnings 0", "check-types": "pnpm run build:css && tsc --noEmit", - "clean": "rm -rf dist src/styles/generated.css" + "clean": "rm -rf dist src/styles/generated.css", + "test": "pnpm run build:css && vitest run" }, "dependencies": { "@ag-ui/client": "0.0.42-alpha.1", @@ -39,7 +40,8 @@ "eslint": "^9.30.0", "tailwindcss": "^4.0.8", "tsup": "^8.5.0", - "typescript": "5.8.2" + "typescript": "5.8.2", + "vitest": "^3.0.5" }, "engines": { "node": ">=18" diff --git a/packages/web-inspector/src/__tests__/web-inspector.spec.ts b/packages/web-inspector/src/__tests__/web-inspector.spec.ts new file mode 100644 index 00000000..572baac0 --- /dev/null +++ b/packages/web-inspector/src/__tests__/web-inspector.spec.ts @@ -0,0 +1,171 @@ +import { WebInspectorElement } from "../index"; +import { + CopilotKitCore, + CopilotKitCoreRuntimeConnectionStatus, + type CopilotKitCoreSubscriber, +} from "@copilotkitnext/core"; +import type { AbstractAgent, AgentSubscriber } from "@ag-ui/client"; +import { describe, it, expect, vi, beforeEach } from "vitest"; + +type MockAgentController = { emit: (key: keyof AgentSubscriber, payload: unknown) => void }; + +type InspectorInternals = { + flattenedEvents: Array<{ type: string }>; + agentMessages: Map>; + agentStates: Map; + cachedTools: Array<{ name: string }>; +}; + +type InspectorContextInternals = { + contextStore: Record; + copyContextValue: (value: unknown, id: string) => Promise; + persistState: () => void; +}; + +type MockAgentExtras = Partial<{ + messages: unknown; + state: unknown; + toolHandlers: Record; + toolRenderers: Record; +}>; + +function createMockAgent( + agentId: string, + extras: MockAgentExtras = {}, +): { agent: AbstractAgent; controller: MockAgentController } { + const subscribers = new Set(); + + const agent = { + agentId, + ...extras, + subscribe(subscriber: AgentSubscriber) { + subscribers.add(subscriber); + return { + unsubscribe: () => subscribers.delete(subscriber), + }; + }, + }; + + const emit = (key: keyof AgentSubscriber, payload: unknown) => { + subscribers.forEach((subscriber) => { + const handler = subscriber[key]; + if (handler) { + (handler as (arg: unknown) => void)(payload); + } + }); + }; + + return { agent: agent as unknown as AbstractAgent, controller: { emit } }; +} + +type MockCore = { + agents: Record; + context: Record; + properties: Record; + runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus; + subscribe: (subscriber: CopilotKitCoreSubscriber) => { unsubscribe: () => void }; +}; + +function createMockCore(initialAgents: Record = {}) { + const subscribers = new Set(); + const core: MockCore = { + agents: initialAgents, + context: {}, + properties: {}, + runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus.Connected, + subscribe(subscriber: CopilotKitCoreSubscriber) { + subscribers.add(subscriber); + return { unsubscribe: () => subscribers.delete(subscriber) }; + }, + }; + + return { + core, + emitAgentsChanged(nextAgents = core.agents) { + core.agents = nextAgents; + subscribers.forEach((subscriber) => + subscriber.onAgentsChanged?.({ + copilotkit: core as unknown as CopilotKitCore, + agents: core.agents, + }), + ); + }, + emitContextChanged(nextContext: Record) { + core.context = nextContext; + subscribers.forEach((subscriber) => + subscriber.onContextChanged?.({ + copilotkit: core as unknown as CopilotKitCore, + context: core.context as unknown as Readonly>, + }), + ); + }, + }; +} + +describe("WebInspectorElement", () => { + beforeEach(() => { + document.body.innerHTML = ""; + localStorage.clear(); + const mockClipboard = { writeText: vi.fn().mockResolvedValue(undefined) }; + (navigator as unknown as { clipboard: typeof mockClipboard }).clipboard = mockClipboard; + }); + + it("records agent events and syncs state/messages/tools", async () => { + const { agent, controller } = createMockAgent("alpha", { + messages: [{ id: "m1", role: "user", content: "hi there" }], + state: { foo: "bar" }, + toolHandlers: { + greet: { description: "hello", parameters: { type: "object" } }, + }, + }); + const { core, emitAgentsChanged } = createMockCore({ alpha: agent }); + + const inspector = new WebInspectorElement(); + document.body.appendChild(inspector); + inspector.core = core as unknown as WebInspectorElement["core"]; + + emitAgentsChanged(); + await inspector.updateComplete; + + controller.emit("onRunStartedEvent", { event: { id: "run-1" } }); + controller.emit("onMessagesSnapshotEvent", { event: { id: "msg-1" } }); + await inspector.updateComplete; + + const inspectorHandle = inspector as unknown as InspectorInternals; + + const flattened = inspectorHandle.flattenedEvents; + expect(flattened.some((evt) => evt.type === "RUN_STARTED")).toBe(true); + expect(flattened.some((evt) => evt.type === "MESSAGES_SNAPSHOT")).toBe(true); + expect(inspectorHandle.agentMessages.get("alpha")?.[0]?.contentText).toContain("hi there"); + expect(inspectorHandle.agentStates.get("alpha")).toBeDefined(); + expect(inspectorHandle.cachedTools.some((tool) => tool.name === "greet")).toBe(true); + }); + + it("normalizes context, persists state, and copies context values", async () => { + const { core, emitContextChanged } = createMockCore(); + const inspector = new WebInspectorElement(); + document.body.appendChild(inspector); + inspector.core = core as unknown as WebInspectorElement["core"]; + + emitContextChanged({ + ctxA: { value: { nested: true } }, + ctxB: { description: "Described", value: 5 }, + }); + await inspector.updateComplete; + + const inspectorHandle = inspector as unknown as InspectorContextInternals; + const contextStore = inspectorHandle.contextStore; + const ctxA = contextStore.ctxA!; + const ctxB = contextStore.ctxB!; + expect(ctxA.value).toMatchObject({ nested: true }); + expect(ctxB.description).toBe("Described"); + + await inspectorHandle.copyContextValue({ nested: true }, "ctxA"); + const clipboard = (navigator as unknown as { clipboard: { writeText: ReturnType } }).clipboard + .writeText as ReturnType; + expect(clipboard).toHaveBeenCalledTimes(1); + + inspectorHandle.persistState(); + expect(localStorage.getItem("copilotkit_inspector_state")).toBeTruthy(); + }); +}); diff --git a/packages/web-inspector/src/index.ts b/packages/web-inspector/src/index.ts index 943582ca..708180c9 100644 --- a/packages/web-inspector/src/index.ts +++ b/packages/web-inspector/src/index.ts @@ -4,7 +4,12 @@ import tailwindStyles from "./styles/generated.css"; import logoMarkUrl from "./assets/logo-mark.svg"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; import { icons } from "lucide"; -import type { CopilotKitCore, CopilotKitCoreSubscriber } from "@copilotkitnext/core"; +import { + CopilotKitCore, + CopilotKitCoreRuntimeConnectionStatus, + type CopilotKitCoreSubscriber, + type CopilotKitCoreErrorCode, +} from "@copilotkitnext/core"; import type { AbstractAgent, AgentSubscriber } from "@ag-ui/client"; import type { Anchor, ContextKey, ContextState, DockMode, Position, Size } from "./lib/types"; import { @@ -43,34 +48,106 @@ const DRAG_THRESHOLD = 6; const MIN_WINDOW_WIDTH = 600; const MIN_WINDOW_WIDTH_DOCKED_LEFT = 420; const MIN_WINDOW_HEIGHT = 200; -const COOKIE_NAME = "copilotkit_inspector_state"; -const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30; // 30 days +const INSPECTOR_STORAGE_KEY = "copilotkit_inspector_state"; const DEFAULT_BUTTON_SIZE: Size = { width: 48, height: 48 }; const DEFAULT_WINDOW_SIZE: Size = { width: 840, height: 560 }; const DOCKED_LEFT_WIDTH = 500; // Sensible width for left dock with collapsed sidebar const MAX_AGENT_EVENTS = 200; const MAX_TOTAL_EVENTS = 500; +type InspectorAgentEventType = + | "RUN_STARTED" + | "RUN_FINISHED" + | "RUN_ERROR" + | "TEXT_MESSAGE_START" + | "TEXT_MESSAGE_CONTENT" + | "TEXT_MESSAGE_END" + | "TOOL_CALL_START" + | "TOOL_CALL_ARGS" + | "TOOL_CALL_END" + | "TOOL_CALL_RESULT" + | "STATE_SNAPSHOT" + | "STATE_DELTA" + | "MESSAGES_SNAPSHOT" + | "RAW_EVENT" + | "CUSTOM_EVENT"; + +const AGENT_EVENT_TYPES: readonly InspectorAgentEventType[] = [ + "RUN_STARTED", + "RUN_FINISHED", + "RUN_ERROR", + "TEXT_MESSAGE_START", + "TEXT_MESSAGE_CONTENT", + "TEXT_MESSAGE_END", + "TOOL_CALL_START", + "TOOL_CALL_ARGS", + "TOOL_CALL_END", + "TOOL_CALL_RESULT", + "STATE_SNAPSHOT", + "STATE_DELTA", + "MESSAGES_SNAPSHOT", + "RAW_EVENT", + "CUSTOM_EVENT", +] as const; + +type SanitizedValue = + | string + | number + | boolean + | null + | SanitizedValue[] + | { [key: string]: SanitizedValue }; + +type InspectorToolCall = { + id?: string; + function?: { + name?: string; + arguments?: SanitizedValue | string; + }; + toolName?: string; + status?: string; +}; + +type InspectorMessage = { + id?: string; + role: string; + contentText: string; + contentRaw?: SanitizedValue; + toolCalls: InspectorToolCall[]; +}; + +type InspectorToolDefinition = { + agentId: string; + name: string; + description?: string; + parameters?: unknown; + type: "handler" | "renderer"; +}; + type InspectorEvent = { id: string; agentId: string; - type: string; + type: InspectorAgentEventType; timestamp: number; - payload: unknown; + payload: SanitizedValue; }; export class WebInspectorElement extends LitElement { static properties = { core: { attribute: false }, + autoAttachCore: { type: Boolean, attribute: "auto-attach-core" }, } as const; private _core: CopilotKitCore | null = null; private coreSubscriber: CopilotKitCoreSubscriber | null = null; private coreUnsubscribe: (() => void) | null = null; + private runtimeStatus: CopilotKitCoreRuntimeConnectionStatus | null = null; + private coreProperties: Readonly> = {}; + private lastCoreError: { code: CopilotKitCoreErrorCode; message: string } | null = null; private agentSubscriptions: Map void> = new Map(); private agentEvents: Map = new Map(); - private agentMessages: Map = new Map(); - private agentStates: Map = new Map(); + private agentMessages: Map = new Map(); + private agentStates: Map = new Map(); private flattenedEvents: InspectorEvent[] = []; private eventCounter = 0; private contextStore: Record = {}; @@ -89,6 +166,12 @@ export class WebInspectorElement extends LitElement { private previousBodyMargins: { left: string; bottom: string } | null = null; private transitionTimeoutId: ReturnType | null = null; private pendingSelectedContext: string | null = null; + private autoAttachCore = true; + private attemptedAutoAttach = false; + private cachedTools: InspectorToolDefinition[] = []; + private toolSignature = ""; + private eventFilterText = ""; + private eventTypeFilter: InspectorAgentEventType | "all" = "all"; get core(): CopilotKitCore | null { return this._core; @@ -136,19 +219,35 @@ export class WebInspectorElement extends LitElement { private isResizing = false; private readonly menuItems: MenuItem[] = [ - { key: "ag-ui-events", label: "AG-UI Events", icon: "Zap" }, - { key: "agents", label: "Agents", icon: "Bot" }, + { key: "ag-ui-events", label: "Events", icon: "Zap" }, + { key: "agents", label: "Agent", icon: "Bot" }, { key: "frontend-tools", label: "Frontend Tools", icon: "Hammer" }, - { key: "agent-context", label: "Agent Context", icon: "FileText" }, + { key: "agent-context", label: "Context", icon: "FileText" }, ]; private attachToCore(core: CopilotKitCore): void { + this.runtimeStatus = core.runtimeConnectionStatus; + this.coreProperties = core.properties; + this.lastCoreError = null; + this.coreSubscriber = { + onRuntimeConnectionStatusChanged: ({ status }) => { + this.runtimeStatus = status; + this.requestUpdate(); + }, + onPropertiesChanged: ({ properties }) => { + this.coreProperties = properties; + this.requestUpdate(); + }, + onError: ({ code, error }) => { + this.lastCoreError = { code, message: error.message }; + this.requestUpdate(); + }, onAgentsChanged: ({ agents }) => { this.processAgentsChanged(agents); }, onContextChanged: ({ context }) => { - this.contextStore = { ...context }; + this.contextStore = this.normalizeContextStore(context); this.requestUpdate(); }, } satisfies CopilotKitCoreSubscriber; @@ -158,7 +257,7 @@ export class WebInspectorElement extends LitElement { // Initialize context from core if (core.context) { - this.contextStore = { ...core.context }; + this.contextStore = this.normalizeContextStore(core.context); } } @@ -168,6 +267,11 @@ export class WebInspectorElement extends LitElement { this.coreUnsubscribe = null; } this.coreSubscriber = null; + this.runtimeStatus = null; + this.lastCoreError = null; + this.coreProperties = {}; + this.cachedTools = []; + this.toolSignature = ""; this.teardownAgentSubscriptions(); } @@ -204,9 +308,62 @@ export class WebInspectorElement extends LitElement { } this.updateContextOptions(seenAgentIds); + this.refreshToolsSnapshot(); this.requestUpdate(); } + private refreshToolsSnapshot(): void { + if (!this._core) { + if (this.cachedTools.length > 0) { + this.cachedTools = []; + this.toolSignature = ""; + this.requestUpdate(); + } + return; + } + + const tools = this.extractToolsFromAgents(); + const signature = JSON.stringify( + tools.map((tool) => ({ + agentId: tool.agentId, + name: tool.name, + type: tool.type, + hasDescription: Boolean(tool.description), + hasParameters: Boolean(tool.parameters), + })), + ); + + if (signature !== this.toolSignature) { + this.toolSignature = signature; + this.cachedTools = tools; + this.requestUpdate(); + } + } + + private tryAutoAttachCore(): void { + if (this.attemptedAutoAttach || this._core || !this.autoAttachCore || typeof window === "undefined") { + return; + } + + this.attemptedAutoAttach = true; + + const globalWindow = window as unknown as Record; + const globalCandidates: Array = [ + // Common app-level globals used during development + globalWindow.__COPILOTKIT_CORE__, + (globalWindow.copilotkit as { core?: unknown } | undefined)?.core, + globalWindow.copilotkitCore, + ]; + + const foundCore = globalCandidates.find( + (candidate): candidate is CopilotKitCore => !!candidate && typeof candidate === "object", + ); + + if (foundCore) { + this.core = foundCore; + } + } + private subscribeToAgent(agent: AbstractAgent): void { if (!agent.agentId) { return; @@ -288,14 +445,15 @@ export class WebInspectorElement extends LitElement { } } - private recordAgentEvent(agentId: string, type: string, payload: unknown): void { + private recordAgentEvent(agentId: string, type: InspectorAgentEventType, payload: unknown): void { const eventId = `${agentId}:${++this.eventCounter}`; + const normalizedPayload = this.normalizeEventPayload(type, payload); const event: InspectorEvent = { id: eventId, agentId, type, timestamp: Date.now(), - payload, + payload: normalizedPayload, }; const currentAgentEvents = this.agentEvents.get(agentId) ?? []; @@ -303,6 +461,7 @@ export class WebInspectorElement extends LitElement { this.agentEvents.set(agentId, nextAgentEvents); this.flattenedEvents = [event, ...this.flattenedEvents].slice(0, MAX_TOTAL_EVENTS); + this.refreshToolsSnapshot(); this.requestUpdate(); } @@ -311,9 +470,8 @@ export class WebInspectorElement extends LitElement { return; } - const messages = (agent as { messages?: unknown }).messages; - - if (Array.isArray(messages)) { + const messages = this.normalizeAgentMessages((agent as { messages?: unknown }).messages); + if (messages) { this.agentMessages.set(agent.agentId, messages); } else { this.agentMessages.delete(agent.agentId); @@ -332,7 +490,7 @@ export class WebInspectorElement extends LitElement { if (state === undefined || state === null) { this.agentStates.delete(agent.agentId); } else { - this.agentStates.set(agent.agentId, state); + this.agentStates.set(agent.agentId, this.sanitizeForLogging(state)); } this.requestUpdate(); @@ -397,17 +555,42 @@ export class WebInspectorElement extends LitElement { return this.agentEvents.get(this.selectedContext) ?? []; } - private getLatestStateForAgent(agentId: string): unknown | null { + private filterEvents(events: InspectorEvent[]): InspectorEvent[] { + const query = this.eventFilterText.trim().toLowerCase(); + + return events.filter((event) => { + if (this.eventTypeFilter !== "all" && event.type !== this.eventTypeFilter) { + return false; + } + + if (!query) { + return true; + } + + const payloadText = this.stringifyPayload(event.payload, false).toLowerCase(); + return ( + event.type.toLowerCase().includes(query) || + event.agentId.toLowerCase().includes(query) || + payloadText.includes(query) + ); + }); + } + + private getLatestStateForAgent(agentId: string): SanitizedValue | null { if (this.agentStates.has(agentId)) { - return this.agentStates.get(agentId); + const value = this.agentStates.get(agentId); + return value === undefined ? null : value; } const events = this.agentEvents.get(agentId) ?? []; const stateEvent = events.find((e) => e.type === "STATE_SNAPSHOT"); - return stateEvent?.payload ?? null; + if (!stateEvent) { + return null; + } + return stateEvent.payload; } - private getLatestMessagesForAgent(agentId: string): unknown[] | null { + private getLatestMessagesForAgent(agentId: string): InspectorMessage[] | null { const messages = this.agentMessages.get(agentId); return messages ?? null; } @@ -445,22 +628,11 @@ export class WebInspectorElement extends LitElement { const messages = this.agentMessages.get(agentId); - const toolCallCount = Array.isArray(messages) - ? (messages as unknown[]).reduce((count, rawMessage) => { - if (!rawMessage || typeof rawMessage !== 'object') { - return count; - } - - const toolCalls = (rawMessage as { toolCalls?: unknown }).toolCalls; - if (!Array.isArray(toolCalls)) { - return count; - } - - return count + toolCalls.length; - }, 0) + const toolCallCount = messages + ? messages.reduce((count, message) => count + (message.toolCalls?.length ?? 0), 0) : events.filter((e) => e.type === "TOOL_CALL_END").length; - const messageCount = Array.isArray(messages) ? messages.length : 0; + const messageCount = messages?.length ?? 0; return { totalEvents: events.length, @@ -471,7 +643,7 @@ export class WebInspectorElement extends LitElement { }; } - private renderToolCallDetails(toolCalls: unknown[]) { + private renderToolCallDetails(toolCalls: InspectorToolCall[]) { if (!Array.isArray(toolCalls) || toolCalls.length === 0) { return nothing; } @@ -479,10 +651,9 @@ export class WebInspectorElement extends LitElement { return html`
${toolCalls.map((call, index) => { - const toolCall = call as any; - const functionName = typeof toolCall?.function?.name === 'string' ? toolCall.function.name : 'Unknown function'; - const callId = typeof toolCall?.id === 'string' ? toolCall.id : `tool-call-${index + 1}`; - const argsString = this.formatToolCallArguments(toolCall?.function?.arguments); + const functionName = call.function?.name ?? call.toolName ?? "Unknown function"; + const callId = typeof call?.id === "string" ? call.id : `tool-call-${index + 1}`; + const argsString = this.formatToolCallArguments(call.function?.arguments); return html`
@@ -508,7 +679,7 @@ export class WebInspectorElement extends LitElement { try { const parsed = JSON.parse(args); return JSON.stringify(parsed, null, 2); - } catch (error) { + } catch { return args; } } @@ -516,7 +687,7 @@ export class WebInspectorElement extends LitElement { if (typeof args === 'object') { try { return JSON.stringify(args, null, 2); - } catch (error) { + } catch { return String(args); } } @@ -622,7 +793,7 @@ export class WebInspectorElement extends LitElement { private extractEventFromPayload(payload: unknown): unknown { // If payload is an object with an 'event' field, extract it if (payload && typeof payload === "object" && "event" in payload) { - return (payload as any).event; + return (payload as Record).event; } // Otherwise, assume the payload itself is the event return payload; @@ -695,6 +866,35 @@ export class WebInspectorElement extends LitElement { z-index: 50; background: transparent; } + + .tooltip-target { + position: relative; + } + + .tooltip-target::after { + content: attr(data-tooltip); + position: absolute; + top: calc(100% + 6px); + left: 50%; + transform: translateX(-50%) translateY(-4px); + white-space: nowrap; + background: rgba(17, 24, 39, 0.95); + color: white; + padding: 4px 8px; + border-radius: 6px; + font-size: 10px; + line-height: 1.2; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15); + opacity: 0; + pointer-events: none; + transition: opacity 120ms ease, transform 120ms ease; + z-index: 4000; + } + + .tooltip-target:hover::after { + opacity: 1; + transform: translateX(-50%) translateY(0); + } `, ]; @@ -705,7 +905,8 @@ export class WebInspectorElement extends LitElement { window.addEventListener("pointerdown", this.handleGlobalPointerDown as EventListener); // Load state early (before first render) so menu selection is correct - this.hydrateStateFromCookieEarly(); + this.hydrateStateFromStorageEarly(); + this.tryAutoAttachCore(); } } @@ -724,6 +925,10 @@ export class WebInspectorElement extends LitElement { return; } + if (!this._core) { + this.tryAutoAttachCore(); + } + this.measureContext("button"); this.measureContext("window"); @@ -733,7 +938,7 @@ export class WebInspectorElement extends LitElement { this.contextState.window.anchor = { horizontal: "right", vertical: "top" }; this.contextState.window.anchorOffset = { x: EDGE_MARGIN, y: EDGE_MARGIN }; - this.hydrateStateFromCookie(); + this.hydrateStateFromStorage(); // Apply docking styles if open and docked (skip transition on initial load) if (this.isOpen && this.dockMode !== 'floating') { @@ -812,7 +1017,6 @@ export class WebInspectorElement extends LitElement { const windowState = this.contextState.window; const isDocked = this.dockMode !== 'floating'; const isTransitioning = this.hasAttribute('data-transitioning'); - const isCollapsed = this.dockMode === 'docked-left'; const windowStyles = isDocked ? this.getDockedWindowStyles() @@ -823,8 +1027,19 @@ export class WebInspectorElement extends LitElement { minHeight: `${MIN_WINDOW_HEIGHT}px`, }; - const contextDropdown = this.renderContextDropdown(); - const hasContextDropdown = contextDropdown !== nothing; + const hasContextDropdown = this.contextOptions.length > 0; + const contextDropdown = hasContextDropdown ? this.renderContextDropdown() : nothing; + const agentOptions = this.contextOptions.filter((opt) => opt.key !== "all-agents"); + const agentCountLabel = agentOptions.length === 1 ? "1 agent" : `${agentOptions.length} agents`; + const coreStatus = this.getCoreStatusSummary(); + const agentSelector = hasContextDropdown + ? contextDropdown + : html` +
+ ${this.renderIcon("Bot")} + No agents available +
+ `; return html`
` : nothing} -
- -
-
-
-
- - 🪁 - CopilotKit Inspector - - - - ${this.renderIcon(this.getSelectedMenu().icon)} - - ${this.getSelectedMenu().label} - ${hasContextDropdown - ? html` - -
${contextDropdown}
- ` - : nothing} -
+
+
+
+ ${this.renderCoreWarningBanner()} + ${this.renderMainContent()} +
-
- ${this.renderDockControls()} - + + ${this.renderIcon("Activity")} + + ${coreStatus.label} + ${coreStatus.description} +
-
- ${this.renderMainContent()} - -
-