Skip to content

Commit c4a99d1

Browse files
committed
feat(web-inspector): Redesign the inspector and connect it to state
1 parent 2013f84 commit c4a99d1

File tree

12 files changed

+1660
-429
lines changed

12 files changed

+1660
-429
lines changed

apps/angular/demo/package.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,22 @@
1919
"@angular/forms": "^18.2.0",
2020
"@angular/platform-browser": "^18.2.0",
2121
"@angular/platform-browser-dynamic": "^18.2.0",
22+
"@ag-ui/client": "0.0.42",
23+
"@ag-ui/core": "0.0.42",
24+
"@ag-ui/encoder": "0.0.42",
25+
"@ag-ui/proto": "0.0.42",
2226
"@copilotkitnext/angular": "workspace:*",
27+
"@copilotkitnext/web-inspector": "workspace:*",
28+
"compare-versions": "^6.1.1",
29+
"fast-json-patch": "^3.1.1",
30+
"lit": "^3.3.1",
31+
"lucide": "^0.525.0",
32+
"partial-json": "^0.1.7",
33+
"untruncate-json": "^0.0.1",
34+
"uuid": "^11.1.0",
2335
"rxjs": "^7.8.1",
2436
"tslib": "^2.8.1",
37+
"zod-to-json-schema": "^3.24.6",
2538
"zod": "^3.25.75",
2639
"zone.js": "^0.14.0"
2740
},
@@ -32,5 +45,10 @@
3245
"nodemon": "^3.1.7",
3346
"rimraf": "^6.0.1",
3447
"typescript": "~5.4.5"
48+
},
49+
"pnpm": {
50+
"publicHoistPattern": [
51+
"*"
52+
]
3553
}
3654
}

apps/angular/demo/src/app/routes/headless/headless-chat.component.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Component, ChangeDetectionStrategy, computed, inject, input, signal } from "@angular/core";
1+
import { Component, ChangeDetectionStrategy, computed, inject, input, signal, OnDestroy, OnInit } from "@angular/core";
22
import { CommonModule } from "@angular/common";
33
import { FormsModule } from "@angular/forms";
44
import {
@@ -10,6 +10,7 @@ import {
1010
registerHumanInTheLoop,
1111
} from "@copilotkitnext/angular";
1212
import { RenderToolCalls } from "@copilotkitnext/angular";
13+
import { WEB_INSPECTOR_TAG, type WebInspectorElement } from "@copilotkitnext/web-inspector";
1314
import { z } from "zod";
1415

1516
@Component({
@@ -76,14 +77,15 @@ export class RequireApprovalComponent implements HumanInTheLoopToolRenderer {
7677
</div>
7778
`,
7879
})
79-
export class HeadlessChatComponent {
80+
export class HeadlessChatComponent implements OnInit, OnDestroy {
8081
readonly agentStore = injectAgentStore("openai");
8182
readonly agent = computed(() => this.agentStore()?.agent);
8283
readonly isRunning = computed(() => !!this.agentStore()?.isRunning());
8384
readonly messages = computed(() => this.agentStore()?.messages());
8485
readonly copilotkit = inject(CopilotKit);
8586

8687
inputValue = "";
88+
private inspectorElement: WebInspectorElement | null = null;
8789

8890
constructor() {
8991
registerHumanInTheLoop({
@@ -104,6 +106,28 @@ export class HeadlessChatComponent {
104106
);
105107
}
106108

109+
ngOnInit(): void {
110+
if (typeof document === "undefined") return;
111+
112+
const existing = document.querySelector<WebInspectorElement>(WEB_INSPECTOR_TAG);
113+
const inspector = existing ?? (document.createElement(WEB_INSPECTOR_TAG) as WebInspectorElement);
114+
inspector.core = this.copilotkit.core;
115+
inspector.setAttribute("auto-attach-core", "false");
116+
117+
if (!existing) {
118+
document.body.appendChild(inspector);
119+
}
120+
121+
this.inspectorElement = inspector;
122+
}
123+
124+
ngOnDestroy(): void {
125+
if (this.inspectorElement && this.inspectorElement.isConnected) {
126+
this.inspectorElement.remove();
127+
}
128+
this.inspectorElement = null;
129+
}
130+
107131
async send() {
108132
const content = this.inputValue.trim();
109133
const agent = this.agent();

apps/angular/demo/tsconfig.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@
2525
"../../packages/shared/dist/index.d.ts",
2626
"../../packages/shared/dist/index.mjs",
2727
"../../packages/shared/src/index.ts"
28+
],
29+
"@copilotkitnext/web-inspector": [
30+
"../../packages/web-inspector/dist/index.d.ts",
31+
"../../packages/web-inspector/src/index.ts"
2832
]
2933
}
3034
},
Lines changed: 29 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,40 @@
11
import * as React from "react";
22
import { createComponent } from "@lit-labs/react";
3-
import {
4-
WEB_INSPECTOR_TAG,
5-
WebInspectorElement,
6-
defineWebInspector,
7-
} from "@copilotkitnext/web-inspector";
83
import type { CopilotKitCore } from "@copilotkitnext/core";
94

10-
defineWebInspector();
5+
type CopilotKitInspectorBaseProps = {
6+
core?: CopilotKitCore | null;
7+
[key: string]: unknown;
8+
};
9+
10+
type InspectorComponent = React.ComponentType<CopilotKitInspectorBaseProps>;
11+
12+
// Lazy-load the lit custom element so consumers don't pay the cost until they render it.
13+
const CopilotKitInspectorBase = React.lazy<InspectorComponent>(() => {
14+
if (typeof window === "undefined") {
15+
const NullComponent: InspectorComponent = () => null;
16+
return Promise.resolve({ default: NullComponent });
17+
}
1118

12-
const CopilotKitInspectorBase = createComponent({
13-
tagName: WEB_INSPECTOR_TAG,
14-
elementClass: WebInspectorElement,
15-
react: React,
19+
return import("@copilotkitnext/web-inspector").then((mod) => {
20+
mod.defineWebInspector?.();
21+
22+
const Component = createComponent({
23+
tagName: mod.WEB_INSPECTOR_TAG,
24+
elementClass: mod.WebInspectorElement,
25+
react: React,
26+
}) as InspectorComponent;
27+
28+
return { default: Component };
29+
});
1630
});
1731

18-
export type CopilotKitInspectorBaseProps = React.ComponentProps<typeof CopilotKitInspectorBase>;
32+
export interface CopilotKitInspectorProps extends CopilotKitInspectorBaseProps {}
1933

20-
export interface CopilotKitInspectorProps extends Omit<CopilotKitInspectorBaseProps, "core"> {
21-
core?: CopilotKitCore | null;
22-
}
23-
24-
export const CopilotKitInspector = React.forwardRef<
25-
WebInspectorElement,
26-
CopilotKitInspectorProps
27-
>(
28-
({ core, ...rest }, ref) => {
29-
const innerRef = React.useRef<WebInspectorElement>(null);
30-
31-
React.useImperativeHandle(ref, () => innerRef.current as WebInspectorElement, []);
32-
33-
React.useEffect(() => {
34-
if (innerRef.current) {
35-
innerRef.current.core = core ?? null;
36-
}
37-
}, [core]);
38-
39-
return (
40-
<CopilotKitInspectorBase
41-
{...(rest as CopilotKitInspectorBaseProps)}
42-
ref={innerRef}
43-
/>
44-
); // eslint-disable-line react/jsx-props-no-spreading
45-
},
34+
export const CopilotKitInspector: React.FC<CopilotKitInspectorProps> = ({ core, ...rest }) => (
35+
<React.Suspense fallback={null}>
36+
<CopilotKitInspectorBase {...rest} core={core ?? null} />
37+
</React.Suspense>
4638
);
4739

4840
CopilotKitInspector.displayName = "CopilotKitInspector";

packages/web-inspector/package.json

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,15 @@
2222
"prepublishOnly": "pnpm run build:css && pnpm run build",
2323
"lint": "eslint . --max-warnings 0",
2424
"check-types": "pnpm run build:css && tsc --noEmit",
25-
"clean": "rm -rf dist src/styles/generated.css"
25+
"clean": "rm -rf dist src/styles/generated.css",
26+
"test": "pnpm run build:css && vitest run"
2627
},
2728
"dependencies": {
2829
"@ag-ui/client": "0.0.42",
2930
"@copilotkitnext/core": "workspace:*",
3031
"lit": "^3.2.0",
31-
"lucide": "^0.525.0"
32+
"lucide": "^0.525.0",
33+
"marked": "^12.0.2"
3234
},
3335
"devDependencies": {
3436
"@copilotkitnext/eslint-config": "workspace:*",
@@ -39,7 +41,8 @@
3941
"eslint": "^9.30.0",
4042
"tailwindcss": "^4.0.8",
4143
"tsup": "^8.5.0",
42-
"typescript": "5.8.2"
44+
"typescript": "5.8.2",
45+
"vitest": "^3.0.5"
4346
},
4447
"engines": {
4548
"node": ">=18"
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import { WebInspectorElement } from "../index";
2+
import {
3+
CopilotKitCore,
4+
CopilotKitCoreRuntimeConnectionStatus,
5+
type CopilotKitCoreSubscriber,
6+
} from "@copilotkitnext/core";
7+
import type { AbstractAgent, AgentSubscriber } from "@ag-ui/client";
8+
import { describe, it, expect, vi, beforeEach } from "vitest";
9+
10+
type MockAgentController = { emit: (key: keyof AgentSubscriber, payload: unknown) => void };
11+
12+
type InspectorInternals = {
13+
flattenedEvents: Array<{ type: string }>;
14+
agentMessages: Map<string, Array<{ contentText?: string }>>;
15+
agentStates: Map<string, unknown>;
16+
cachedTools: Array<{ name: string }>;
17+
};
18+
19+
type InspectorContextInternals = {
20+
contextStore: Record<string, { description?: string; value: unknown }>;
21+
copyContextValue: (value: unknown, id: string) => Promise<void>;
22+
persistState: () => void;
23+
};
24+
25+
type MockAgentExtras = Partial<{
26+
messages: unknown;
27+
state: unknown;
28+
toolHandlers: Record<string, unknown>;
29+
toolRenderers: Record<string, unknown>;
30+
}>;
31+
32+
function createMockAgent(
33+
agentId: string,
34+
extras: MockAgentExtras = {},
35+
): { agent: AbstractAgent; controller: MockAgentController } {
36+
const subscribers = new Set<AgentSubscriber>();
37+
38+
const agent = {
39+
agentId,
40+
...extras,
41+
subscribe(subscriber: AgentSubscriber) {
42+
subscribers.add(subscriber);
43+
return {
44+
unsubscribe: () => subscribers.delete(subscriber),
45+
};
46+
},
47+
};
48+
49+
const emit = (key: keyof AgentSubscriber, payload: unknown) => {
50+
subscribers.forEach((subscriber) => {
51+
const handler = subscriber[key];
52+
if (handler) {
53+
(handler as (arg: unknown) => void)(payload);
54+
}
55+
});
56+
};
57+
58+
return { agent: agent as unknown as AbstractAgent, controller: { emit } };
59+
}
60+
61+
type MockCore = {
62+
agents: Record<string, AbstractAgent>;
63+
context: Record<string, unknown>;
64+
properties: Record<string, unknown>;
65+
runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus;
66+
subscribe: (subscriber: CopilotKitCoreSubscriber) => { unsubscribe: () => void };
67+
};
68+
69+
function createMockCore(initialAgents: Record<string, AbstractAgent> = {}) {
70+
const subscribers = new Set<CopilotKitCoreSubscriber>();
71+
const core: MockCore = {
72+
agents: initialAgents,
73+
context: {},
74+
properties: {},
75+
runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus.Connected,
76+
subscribe(subscriber: CopilotKitCoreSubscriber) {
77+
subscribers.add(subscriber);
78+
return { unsubscribe: () => subscribers.delete(subscriber) };
79+
},
80+
};
81+
82+
return {
83+
core,
84+
emitAgentsChanged(nextAgents = core.agents) {
85+
core.agents = nextAgents;
86+
subscribers.forEach((subscriber) =>
87+
subscriber.onAgentsChanged?.({
88+
copilotkit: core as unknown as CopilotKitCore,
89+
agents: core.agents,
90+
}),
91+
);
92+
},
93+
emitContextChanged(nextContext: Record<string, unknown>) {
94+
core.context = nextContext;
95+
subscribers.forEach((subscriber) =>
96+
subscriber.onContextChanged?.({
97+
copilotkit: core as unknown as CopilotKitCore,
98+
context: core.context as unknown as Readonly<Record<string, { value: string; description: string }>>,
99+
}),
100+
);
101+
},
102+
};
103+
}
104+
105+
describe("WebInspectorElement", () => {
106+
beforeEach(() => {
107+
document.body.innerHTML = "";
108+
localStorage.clear();
109+
const mockClipboard = { writeText: vi.fn().mockResolvedValue(undefined) };
110+
(navigator as unknown as { clipboard: typeof mockClipboard }).clipboard = mockClipboard;
111+
});
112+
113+
it("records agent events and syncs state/messages/tools", async () => {
114+
const { agent, controller } = createMockAgent("alpha", {
115+
messages: [{ id: "m1", role: "user", content: "hi there" }],
116+
state: { foo: "bar" },
117+
toolHandlers: {
118+
greet: { description: "hello", parameters: { type: "object" } },
119+
},
120+
});
121+
const { core, emitAgentsChanged } = createMockCore({ alpha: agent });
122+
123+
const inspector = new WebInspectorElement();
124+
document.body.appendChild(inspector);
125+
inspector.core = core as unknown as WebInspectorElement["core"];
126+
127+
emitAgentsChanged();
128+
await inspector.updateComplete;
129+
130+
controller.emit("onRunStartedEvent", { event: { id: "run-1" } });
131+
controller.emit("onMessagesSnapshotEvent", { event: { id: "msg-1" } });
132+
await inspector.updateComplete;
133+
134+
const inspectorHandle = inspector as unknown as InspectorInternals;
135+
136+
const flattened = inspectorHandle.flattenedEvents;
137+
expect(flattened.some((evt) => evt.type === "RUN_STARTED")).toBe(true);
138+
expect(flattened.some((evt) => evt.type === "MESSAGES_SNAPSHOT")).toBe(true);
139+
expect(inspectorHandle.agentMessages.get("alpha")?.[0]?.contentText).toContain("hi there");
140+
expect(inspectorHandle.agentStates.get("alpha")).toBeDefined();
141+
expect(inspectorHandle.cachedTools.some((tool) => tool.name === "greet")).toBe(true);
142+
});
143+
144+
it("normalizes context, persists state, and copies context values", async () => {
145+
const { core, emitContextChanged } = createMockCore();
146+
const inspector = new WebInspectorElement();
147+
document.body.appendChild(inspector);
148+
inspector.core = core as unknown as WebInspectorElement["core"];
149+
150+
emitContextChanged({
151+
ctxA: { value: { nested: true } },
152+
ctxB: { description: "Described", value: 5 },
153+
});
154+
await inspector.updateComplete;
155+
156+
const inspectorHandle = inspector as unknown as InspectorContextInternals;
157+
const contextStore = inspectorHandle.contextStore;
158+
const ctxA = contextStore.ctxA!;
159+
const ctxB = contextStore.ctxB!;
160+
expect(ctxA.value).toMatchObject({ nested: true });
161+
expect(ctxB.description).toBe("Described");
162+
163+
await inspectorHandle.copyContextValue({ nested: true }, "ctxA");
164+
const clipboard = (navigator as unknown as { clipboard: { writeText: ReturnType<typeof vi.fn> } }).clipboard
165+
.writeText as ReturnType<typeof vi.fn>;
166+
expect(clipboard).toHaveBeenCalledTimes(1);
167+
168+
inspectorHandle.persistState();
169+
expect(localStorage.getItem("cpk:inspector:state")).toBeTruthy();
170+
});
171+
});
File renamed without changes.

0 commit comments

Comments
 (0)