Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions apps/angular/demo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,22 @@
"@angular/forms": "^18.2.0",
"@angular/platform-browser": "^18.2.0",
"@angular/platform-browser-dynamic": "^18.2.0",
"@ag-ui/client": "0.0.42",
"@ag-ui/core": "0.0.42",
"@ag-ui/encoder": "0.0.42",
"@ag-ui/proto": "0.0.42",
"@copilotkitnext/angular": "workspace:*",
"@copilotkitnext/web-inspector": "workspace:*",
"compare-versions": "^6.1.1",
"fast-json-patch": "^3.1.1",
"lit": "^3.3.1",
"lucide": "^0.525.0",
"partial-json": "^0.1.7",
"untruncate-json": "^0.0.1",
"uuid": "^11.1.0",
"rxjs": "^7.8.1",
"tslib": "^2.8.1",
"zod-to-json-schema": "^3.24.6",
"zod": "^3.25.75",
"zone.js": "^0.14.0"
},
Expand All @@ -32,5 +45,10 @@
"nodemon": "^3.1.7",
"rimraf": "^6.0.1",
"typescript": "~5.4.5"
},
"pnpm": {
"publicHoistPattern": [
"*"
]
}
}
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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({
Expand Down Expand Up @@ -76,14 +77,15 @@ export class RequireApprovalComponent implements HumanInTheLoopToolRenderer {
</div>
`,
})
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());
readonly messages = computed(() => this.agentStore()?.messages());
readonly copilotkit = inject(CopilotKit);

inputValue = "";
private inspectorElement: WebInspectorElement | null = null;

constructor() {
registerHumanInTheLoop({
Expand All @@ -104,6 +106,28 @@ export class HeadlessChatComponent {
);
}

ngOnInit(): void {
if (typeof document === "undefined") return;

const existing = document.querySelector<WebInspectorElement>(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();
Expand Down
4 changes: 4 additions & 0 deletions apps/angular/demo/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
}
},
Expand Down
66 changes: 29 additions & 37 deletions packages/react/src/components/CopilotKitInspector.tsx
Original file line number Diff line number Diff line change
@@ -1,48 +1,40 @@
import * as React from "react";
import { createComponent } from "@lit-labs/react";
import {
WEB_INSPECTOR_TAG,
WebInspectorElement,
defineWebInspector,
} from "@copilotkitnext/web-inspector";
import type { CopilotKitCore } from "@copilotkitnext/core";

defineWebInspector();
type CopilotKitInspectorBaseProps = {
core?: CopilotKitCore | null;
[key: string]: unknown;
};

type InspectorComponent = React.ComponentType<CopilotKitInspectorBaseProps>;

// Lazy-load the lit custom element so consumers don't pay the cost until they render it.
const CopilotKitInspectorBase = React.lazy<InspectorComponent>(() => {
if (typeof window === "undefined") {
const NullComponent: InspectorComponent = () => null;
return Promise.resolve({ default: NullComponent });
}

const CopilotKitInspectorBase = createComponent({
tagName: WEB_INSPECTOR_TAG,
elementClass: WebInspectorElement,
react: React,
return import("@copilotkitnext/web-inspector").then((mod) => {
mod.defineWebInspector?.();

const Component = createComponent({
tagName: mod.WEB_INSPECTOR_TAG,
elementClass: mod.WebInspectorElement,
react: React,
}) as InspectorComponent;

return { default: Component };
});
});

export type CopilotKitInspectorBaseProps = React.ComponentProps<typeof CopilotKitInspectorBase>;
export interface CopilotKitInspectorProps extends CopilotKitInspectorBaseProps {}

export interface CopilotKitInspectorProps extends Omit<CopilotKitInspectorBaseProps, "core"> {
core?: CopilotKitCore | null;
}

export const CopilotKitInspector = React.forwardRef<
WebInspectorElement,
CopilotKitInspectorProps
>(
({ core, ...rest }, ref) => {
const innerRef = React.useRef<WebInspectorElement>(null);

React.useImperativeHandle(ref, () => innerRef.current as WebInspectorElement, []);

React.useEffect(() => {
if (innerRef.current) {
innerRef.current.core = core ?? null;
}
}, [core]);

return (
<CopilotKitInspectorBase
{...(rest as CopilotKitInspectorBaseProps)}
ref={innerRef}
/>
); // eslint-disable-line react/jsx-props-no-spreading
},
export const CopilotKitInspector: React.FC<CopilotKitInspectorProps> = ({ core, ...rest }) => (
<React.Suspense fallback={null}>
<CopilotKitInspectorBase {...rest} core={core ?? null} />
</React.Suspense>
);

CopilotKitInspector.displayName = "CopilotKitInspector";
9 changes: 6 additions & 3 deletions packages/web-inspector/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,15 @@
"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",
"@copilotkitnext/core": "workspace:*",
"lit": "^3.2.0",
"lucide": "^0.525.0"
"lucide": "^0.525.0",
"marked": "^12.0.2"
},
"devDependencies": {
"@copilotkitnext/eslint-config": "workspace:*",
Expand All @@ -39,7 +41,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"
Expand Down
171 changes: 171 additions & 0 deletions packages/web-inspector/src/__tests__/web-inspector.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, Array<{ contentText?: string }>>;
agentStates: Map<string, unknown>;
cachedTools: Array<{ name: string }>;
};

type InspectorContextInternals = {
contextStore: Record<string, { description?: string; value: unknown }>;
copyContextValue: (value: unknown, id: string) => Promise<void>;
persistState: () => void;
};

type MockAgentExtras = Partial<{
messages: unknown;
state: unknown;
toolHandlers: Record<string, unknown>;
toolRenderers: Record<string, unknown>;
}>;

function createMockAgent(
agentId: string,
extras: MockAgentExtras = {},
): { agent: AbstractAgent; controller: MockAgentController } {
const subscribers = new Set<AgentSubscriber>();

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<string, AbstractAgent>;
context: Record<string, unknown>;
properties: Record<string, unknown>;
runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus;
subscribe: (subscriber: CopilotKitCoreSubscriber) => { unsubscribe: () => void };
};

function createMockCore(initialAgents: Record<string, AbstractAgent> = {}) {
const subscribers = new Set<CopilotKitCoreSubscriber>();
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<string, unknown>) {
core.context = nextContext;
subscribers.forEach((subscriber) =>
subscriber.onContextChanged?.({
copilotkit: core as unknown as CopilotKitCore,
context: core.context as unknown as Readonly<Record<string, { value: string; description: string }>>,
}),
);
},
};
}

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<typeof vi.fn> } }).clipboard
.writeText as ReturnType<typeof vi.fn>;
expect(clipboard).toHaveBeenCalledTimes(1);

inspectorHandle.persistState();
expect(localStorage.getItem("cpk:inspector:state")).toBeTruthy();
});
});
Loading