mirror of
https://github.com/CopilotKit/CopilotKit.git
synced 2026-09-14 16:26:20 +08:00
feat: create use-interrupt hook (#3184)
Co-authored-by: Alem Tuzlak <t.zlak@hotmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@copilotkit/react-core": patch
|
||||
"@copilotkitnext/react": patch
|
||||
"@copilotkitnext/core": patch
|
||||
---
|
||||
|
||||
Added in the useInterrupt hook
|
||||
@@ -42,3 +42,4 @@ debug-storybook.log
|
||||
storybook-static
|
||||
coverage
|
||||
.turbo
|
||||
.langgraph_api
|
||||
@@ -12,6 +12,8 @@
|
||||
"---Frontend Tools---",
|
||||
"useFrontendTool",
|
||||
"useHumanInTheLoop",
|
||||
"useInterrupt",
|
||||
"useRenderToolCall",
|
||||
"---Suggestions---",
|
||||
"useSuggestions",
|
||||
"useConfigureSuggestions",
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
---
|
||||
title: "useInterrupt"
|
||||
description: "React hook for handling agent interrupt events and resuming execution with user input"
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
`useInterrupt` listens for agent custom events named `on_interrupt`, captures the latest interrupt payload for a run, and renders interrupt UI once the run finalizes. Your UI can call `resolve(response)` to resume the agent with a resume payload.
|
||||
|
||||
By default, interrupt UI is rendered inside `<CopilotChat>` automatically. If you set `renderInChat: false`, the hook returns the element so you can place it manually.
|
||||
|
||||
`event.value` is typed as `any` since the interrupt payload shape depends on your agent. Type-narrow it in your callbacks (e.g. `handler`, `enabled`, `render`) as needed.
|
||||
|
||||
## Signature
|
||||
|
||||
```tsx
|
||||
import { useInterrupt } from "@copilotkit/react-core/v2";
|
||||
|
||||
function useInterrupt<
|
||||
TResult = never,
|
||||
TRenderInChat extends boolean | undefined = undefined,
|
||||
>(
|
||||
config: UseInterruptConfig<any, TResult, TRenderInChat>,
|
||||
): TRenderInChat extends false
|
||||
? React.ReactElement | null
|
||||
: TRenderInChat extends true | undefined
|
||||
? void
|
||||
: React.ReactElement | null | void;
|
||||
```
|
||||
|
||||
## Parameters
|
||||
|
||||
<PropertyReference name="config" type="UseInterruptConfig<any, TResult, TRenderInChat>" required>
|
||||
Interrupt configuration.
|
||||
|
||||
<PropertyReference name="render" type="(props: InterruptRenderProps<any, TResult | null>) => React.ReactElement" required>
|
||||
Render callback for interrupt UI. Called when an interrupt is available. The callback receives:
|
||||
- `event` -- interrupt event (`{ name, value }`). `value` is `any`; type-narrow in your callback as needed.
|
||||
- `result` -- inferred from `handler` return type, or `null`
|
||||
- `resolve(response)` -- resumes the agent with `command.resume = response`
|
||||
</PropertyReference>
|
||||
|
||||
<PropertyReference name="handler" type="(props: InterruptHandlerProps) => TResult | PromiseLike<TResult>">
|
||||
Optional preprocessing callback. Runs before rendering and can return sync or async data that is exposed as `result` in `render`.
|
||||
`TResult` is automatically inferred from the handler's return type.
|
||||
If the handler throws/rejects, `result` is `null`.
|
||||
</PropertyReference>
|
||||
|
||||
<PropertyReference name="enabled" type="(event: InterruptEvent) => boolean">
|
||||
Optional filter. Return `false` to ignore matching interrupts for this hook instance.
|
||||
</PropertyReference>
|
||||
|
||||
<PropertyReference name="agentId" type="string">
|
||||
Optional agent id. Defaults to the currently configured chat agent.
|
||||
</PropertyReference>
|
||||
|
||||
<PropertyReference name="renderInChat" type="boolean" default="true">
|
||||
Controls where UI renders:
|
||||
- `true` (default): publishes interrupt UI into `<CopilotChat>`
|
||||
- `false`: returns interrupt element from the hook for manual placement
|
||||
</PropertyReference>
|
||||
</PropertyReference>
|
||||
|
||||
## Return Value
|
||||
|
||||
<PropertyReference name="element" type="Conditional by renderInChat">
|
||||
Return type is inferred from `renderInChat`:
|
||||
- `renderInChat: false` -> `React.ReactElement | null`
|
||||
- `renderInChat: true` or omitted -> `void`
|
||||
- dynamic boolean -> `React.ReactElement | null | void`
|
||||
</PropertyReference>
|
||||
|
||||
## Usage
|
||||
|
||||
### In-chat interrupt UI (default)
|
||||
|
||||
```tsx
|
||||
import { useInterrupt } from "@copilotkit/react-core/v2";
|
||||
|
||||
function ApprovalInterrupt() {
|
||||
useInterrupt({
|
||||
render: ({ event, resolve }) => (
|
||||
<div className="p-3 border rounded">
|
||||
<p>{event.value.question}</p>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<button onClick={() => resolve({ approved: true })}>Approve</button>
|
||||
<button onClick={() => resolve({ approved: false })}>Reject</button>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
### Manual placement with async preprocessing
|
||||
|
||||
```tsx
|
||||
import { useInterrupt } from "@copilotkit/react-core/v2";
|
||||
|
||||
function SidePanelInterrupt() {
|
||||
const element = useInterrupt({
|
||||
renderInChat: false,
|
||||
enabled: (event) => event.value.startsWith("approval:"),
|
||||
handler: async ({ event }) => ({ label: event.value.toUpperCase() }),
|
||||
render: ({ event, result, resolve }) => (
|
||||
<aside className="rounded border p-3">
|
||||
<div className="font-medium">{result?.label ?? ""}</div>
|
||||
<div className="mt-2">{event.value}</div>
|
||||
<button className="mt-2" onClick={() => resolve({ accepted: true })}>
|
||||
Continue
|
||||
</button>
|
||||
</aside>
|
||||
),
|
||||
});
|
||||
|
||||
return <>{element}</>;
|
||||
}
|
||||
```
|
||||
|
||||
## Behavior
|
||||
|
||||
- Interrupts are collected from agent custom events named `on_interrupt`.
|
||||
- Interrupt UI is surfaced when the run finalizes.
|
||||
- Starting a new run clears pending interrupt state.
|
||||
- `event.value` is `any` -- type-narrow in your callbacks as needed.
|
||||
- `render.result` is inferred from `handler` return type and is always `TResult | null`.
|
||||
- If `handler` throws or rejects, `result` is set to `null`.
|
||||
|
||||
## Related
|
||||
|
||||
- [`useHumanInTheLoop`](/reference/v2/hooks/useHumanInTheLoop) -- structured interactive tool workflows
|
||||
- [`useFrontendTool`](/reference/v2/hooks/useFrontendTool) -- client-side tool registration
|
||||
- [`useAgent`](/reference/v2/hooks/useAgent) -- access and subscribe to agent events
|
||||
@@ -0,0 +1,50 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
.next/
|
||||
/out/
|
||||
|
||||
# turbo
|
||||
.turbo
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# lock files
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
bun.lockb
|
||||
@@ -0,0 +1,21 @@
|
||||
The MIT License
|
||||
|
||||
Copyright (c) Atai Barkai
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -0,0 +1,106 @@
|
||||
# CopilotKit <> LangGraph Starter
|
||||
|
||||
This is a starter template for building AI agents using [LangGraph](https://www.langchain.com/langgraph) and [CopilotKit](https://copilotkit.ai). It provides a modern Next.js application with an integrated LangGraph agent to be built on top of.
|
||||
|
||||
This project is organized as a monorepo using [Turborepo](https://turbo.build) and [pnpm workspaces](https://pnpm.io/workspaces).
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
.
|
||||
├── apps/
|
||||
│ ├── web/ # Next.js frontend application
|
||||
│ └── agent/ # LangGraph agent
|
||||
├── pnpm-workspace.yaml
|
||||
├── turbo.json
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- [pnpm](https://pnpm.io/installation) 9.15.0 or later
|
||||
- OpenAI API Key (for the LangGraph agent)
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Install all dependencies (this installs everything for both apps):
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
2. Set up your OpenAI API key:
|
||||
|
||||
```bash
|
||||
cd apps/agent
|
||||
echo "OPENAI_API_KEY=your-openai-api-key-here" > .env
|
||||
```
|
||||
|
||||
3. Start the development servers:
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
This will start both the Next.js app (on port 3000) and the LangGraph agent (on port 8123) using Turborepo.
|
||||
|
||||
## Available Scripts
|
||||
|
||||
All scripts use Turborepo to run tasks across the monorepo:
|
||||
|
||||
- `pnpm dev` - Starts both the web app and agent servers in development mode
|
||||
- `pnpm dev:studio` - Starts the web app and agent with LangGraph Studio UI
|
||||
- `pnpm build` - Builds all apps for production
|
||||
- `pnpm lint` - Runs linting across all apps
|
||||
|
||||
### Running Scripts for Individual Apps
|
||||
|
||||
You can also run scripts for individual apps using pnpm's filter flag:
|
||||
|
||||
```bash
|
||||
# Run dev for just the web app
|
||||
pnpm --filter web dev
|
||||
|
||||
# Run dev for just the agent
|
||||
pnpm --filter agent dev
|
||||
|
||||
# Or navigate to the app directory
|
||||
cd apps/web
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## Customization
|
||||
|
||||
The main UI component is in `apps/web/src/app/page.tsx`. You can:
|
||||
|
||||
- Modify the theme colors and styling
|
||||
- Add new frontend actions
|
||||
- Utilize shared-state
|
||||
- Customize your user-interface for interacting with LangGraph
|
||||
|
||||
The LangGraph agent code is in `apps/agent/src/`.
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
- [CopilotKit Documentation](https://docs.copilotkit.ai) - Explore CopilotKit's capabilities
|
||||
- [LangGraph Documentation](https://langchain-ai.github.io/langgraph/) - Learn more about LangGraph and its features
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - Learn about Next.js features and API
|
||||
|
||||
## Contributing
|
||||
|
||||
Feel free to submit issues and enhancement requests! This starter is designed to be easily extensible.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License - see the LICENSE file for details.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Agent Connection Issues
|
||||
|
||||
If you see "I'm having trouble connecting to my tools", make sure:
|
||||
|
||||
1. The LangGraph agent is running on port 8000
|
||||
2. Your OpenAI API key is set correctly
|
||||
3. Both servers started successfully
|
||||
@@ -0,0 +1,9 @@
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
.vercel
|
||||
|
||||
# LangGraph API
|
||||
.langgraph_api
|
||||
node_modules/
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"node_version": "20",
|
||||
"dockerfile_lines": [],
|
||||
"dependencies": ["."],
|
||||
"graphs": {
|
||||
"default": "./src/agent.ts:graph"
|
||||
},
|
||||
"env": ".env"
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "agent-langraph-interrupt",
|
||||
"version": "0.0.1",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "npx @langchain/langgraph-cli dev --port 8125 --no-browser"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@types/html-to-text": "^9.0.4",
|
||||
"@types/node": "^22.19.11",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@copilotkit/sdk-js": "^1.51.4",
|
||||
"@langchain/core": "^1.1.26",
|
||||
"@langchain/langgraph": "1.1.5",
|
||||
"@langchain/langgraph-checkpoint": "1.0.0",
|
||||
"@langchain/openai": "^1.2.8",
|
||||
"zod": "^3.25.76"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "agent-langraph-interrupt",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "apps/agent/src",
|
||||
"projectType": "application",
|
||||
"targets": {
|
||||
"dev": {
|
||||
"executor": "nx:run-commands",
|
||||
"options": {
|
||||
"command": "pnpm --filter agent-langraph-interrupt dev"
|
||||
}
|
||||
},
|
||||
"build": {
|
||||
"executor": "nx:run-commands",
|
||||
"options": {
|
||||
"command": "node -e \"console.log('Agent app has no separate build target yet.')\""
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "nx:run-commands",
|
||||
"options": {
|
||||
"command": "node -e \"console.log('Agent app has no lint target yet.')\""
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": []
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* This is the main entry point for the agent.
|
||||
* It defines the workflow graph, state, tools, nodes and edges.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
import { RunnableConfig } from "@langchain/core/runnables";
|
||||
import { tool } from "@langchain/core/tools";
|
||||
import { ToolNode } from "@langchain/langgraph/prebuilt";
|
||||
import { AIMessage, SystemMessage } from "@langchain/core/messages";
|
||||
import {
|
||||
interrupt,
|
||||
MemorySaver,
|
||||
START,
|
||||
StateGraph,
|
||||
} from "@langchain/langgraph";
|
||||
import { ChatOpenAI } from "@langchain/openai";
|
||||
import {
|
||||
convertActionsToDynamicStructuredTools,
|
||||
CopilotKitStateAnnotation,
|
||||
} from "@copilotkit/sdk-js/langgraph";
|
||||
import { Annotation } from "@langchain/langgraph";
|
||||
|
||||
// 1. Define our agent state, which includes CopilotKit state to
|
||||
// provide actions to the state.
|
||||
const AgentStateAnnotation = Annotation.Root({
|
||||
...CopilotKitStateAnnotation.spec, // CopilotKit state annotation already includes messages, as well as frontend tools
|
||||
proverbs: Annotation<string[]>,
|
||||
});
|
||||
|
||||
// 2. Define the type for our agent state
|
||||
export type AgentState = typeof AgentStateAnnotation.State;
|
||||
|
||||
// 3. Define a simple tool to get the weather statically
|
||||
const getWeather = tool(
|
||||
(args) => {
|
||||
return `The weather for ${args.location} is 70 degrees, clear skies, 45% humidity, 5 mph wind, and feels like 72 degrees.`;
|
||||
},
|
||||
{
|
||||
name: "getWeather",
|
||||
description: "Get the weather for a given location.",
|
||||
schema: z.object({
|
||||
location: z.string().describe("The location to get weather for"),
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
// 4. Define a tool that triggers a human-in-the-loop interrupt
|
||||
const deleteProverb = tool(
|
||||
async (args) => {
|
||||
const approval = interrupt({
|
||||
action: "delete_proverb",
|
||||
proverb: args.proverb,
|
||||
message: `Are you sure you want to delete the proverb: "${args.proverb}"?`,
|
||||
});
|
||||
|
||||
if (approval?.approved) {
|
||||
return `Proverb "${args.proverb}" has been deleted.`;
|
||||
}
|
||||
return `Deletion of proverb "${args.proverb}" was cancelled by the user.`;
|
||||
},
|
||||
{
|
||||
name: "deleteProverb",
|
||||
description:
|
||||
"Delete a proverb from the list. This will ask the user for confirmation before deleting.",
|
||||
schema: z.object({
|
||||
proverb: z.string().describe("The proverb to delete"),
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
// 5. Put our tools into an array
|
||||
const tools = [getWeather, deleteProverb];
|
||||
|
||||
// 5. Define the chat node, which will handle the chat logic
|
||||
async function chat_node(state: AgentState, config: RunnableConfig) {
|
||||
// 5.1 Define the model, lower temperature for deterministic responses
|
||||
const model = new ChatOpenAI({ model: "gpt-4o-mini" });
|
||||
|
||||
// 5.2 Bind the tools to the model, include CopilotKit actions. This allows
|
||||
// the model to call tools that are defined in CopilotKit by the frontend.
|
||||
const modelWithTools = model.bindTools!([
|
||||
...convertActionsToDynamicStructuredTools(state.copilotkit?.actions ?? []),
|
||||
...tools,
|
||||
]);
|
||||
|
||||
// 5.3 Define the system message, which will be used to guide the model, in this case
|
||||
// we also add in the language to use from the state.
|
||||
const systemMessage = new SystemMessage({
|
||||
content: `You are a helpful assistant. The current proverbs are ${JSON.stringify(state.proverbs)}. If a user asks to delete a proverb, call deleteProverb to trigger a human-in-the-loop interrupt for confirmation.`,
|
||||
});
|
||||
|
||||
// 5.4 Invoke the model with the system message and the messages in the state
|
||||
const response = await modelWithTools.invoke(
|
||||
[systemMessage, ...state.messages],
|
||||
config,
|
||||
);
|
||||
|
||||
// 5.5 Return the response, which will be added to the state
|
||||
return {
|
||||
messages: response,
|
||||
};
|
||||
}
|
||||
|
||||
// 6. Define the function that determines whether to continue or not,
|
||||
// this is used to determine the next node to run
|
||||
function shouldContinue({ messages, copilotkit }: AgentState) {
|
||||
// 6.1 Get the last message from the state
|
||||
const lastMessage = messages[messages.length - 1] as AIMessage;
|
||||
|
||||
// 7.2 If the LLM makes a tool call, then we route to the "tools" node
|
||||
if (lastMessage.tool_calls?.length) {
|
||||
// Actions are the frontend tools coming from CopilotKit
|
||||
const actions = copilotkit?.actions;
|
||||
const toolCallName = lastMessage.tool_calls![0].name;
|
||||
|
||||
// 7.3 Only route to the tool node if the tool call is not a CopilotKit action
|
||||
if (!actions || actions.every((action) => action.name !== toolCallName)) {
|
||||
return "tool_node";
|
||||
}
|
||||
}
|
||||
|
||||
// 6.4 Otherwise, we stop (reply to the user) using the special "__end__" node
|
||||
return "__end__";
|
||||
}
|
||||
|
||||
// Define the workflow graph
|
||||
const workflow = new StateGraph(AgentStateAnnotation)
|
||||
.addNode("chat_node", chat_node)
|
||||
.addNode("tool_node", new ToolNode(tools))
|
||||
.addEdge(START, "chat_node")
|
||||
.addEdge("tool_node", "chat_node")
|
||||
.addConditionalEdges("chat_node", shouldContinue as any);
|
||||
|
||||
const memory = new MemorySaver();
|
||||
|
||||
export const graph = workflow.compile({
|
||||
checkpointer: memory,
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
/* Visit https://aka.ms/tsconfig to read more about this file */
|
||||
|
||||
/* Projects */
|
||||
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
|
||||
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
|
||||
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
|
||||
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
|
||||
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
|
||||
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
||||
|
||||
/* Language and Environment */
|
||||
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
|
||||
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
||||
// "jsx": "preserve", /* Specify what JSX code is generated. */
|
||||
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
|
||||
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
|
||||
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
|
||||
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
|
||||
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
|
||||
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
|
||||
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
||||
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
|
||||
|
||||
/* Modules */
|
||||
"module": "Node16", /* Specify what module code is generated. */
|
||||
// "rootDir": "./", /* Specify the root folder within your source files. */
|
||||
"moduleResolution": "node16", /* Specify how TypeScript looks up a file from a given module specifier. */
|
||||
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
||||
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
|
||||
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
||||
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
|
||||
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
|
||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
|
||||
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
|
||||
"resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
|
||||
"resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
|
||||
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
|
||||
// "noUncheckedSideEffectImports": true, /* Check side effect imports. */
|
||||
// "resolveJsonModule": true, /* Enable importing .json files. */
|
||||
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
|
||||
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
|
||||
|
||||
/* JavaScript Support */
|
||||
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
|
||||
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
|
||||
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
|
||||
|
||||
/* Emit */
|
||||
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
|
||||
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
|
||||
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
|
||||
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
|
||||
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
|
||||
// "noEmit": true, /* Disable emitting files from a compilation. */
|
||||
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
|
||||
// "outDir": "./", /* Specify an output folder for all emitted files. */
|
||||
// "removeComments": true, /* Disable emitting comments. */
|
||||
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
|
||||
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
|
||||
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
|
||||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
|
||||
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
|
||||
// "newLine": "crlf", /* Set the newline character for emitting files. */
|
||||
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
|
||||
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
|
||||
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
|
||||
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
|
||||
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
|
||||
|
||||
/* Interop Constraints */
|
||||
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
|
||||
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
|
||||
// "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */
|
||||
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
|
||||
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
|
||||
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
|
||||
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
|
||||
|
||||
/* Type Checking */
|
||||
"strict": true, /* Enable all strict type-checking options. */
|
||||
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
|
||||
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
|
||||
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
|
||||
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
|
||||
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
|
||||
// "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */
|
||||
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
|
||||
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
|
||||
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
|
||||
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
|
||||
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
|
||||
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
|
||||
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
|
||||
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
|
||||
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
|
||||
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
|
||||
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
|
||||
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
|
||||
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
|
||||
|
||||
/* Completeness */
|
||||
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
serverExternalPackages: ["@copilotkit/runtime"],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "web-langraph-interrupt",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@copilotkit/react-core": "workspace:*",
|
||||
"@copilotkit/react-ui": "workspace:*",
|
||||
"@copilotkit/runtime": "workspace:*",
|
||||
"next": "16.0.8",
|
||||
"react": "^19.2.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"shiki": "^3.22.0",
|
||||
"zod": "^3.24.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.0.8",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "web-langraph-interrupt",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "apps/web/src",
|
||||
"projectType": "application",
|
||||
"targets": {
|
||||
"dev": {
|
||||
"executor": "nx:run-commands",
|
||||
"options": {
|
||||
"command": "pnpm --filter web-langraph-interrupt dev"
|
||||
}
|
||||
},
|
||||
"build": {
|
||||
"executor": "nx:run-commands",
|
||||
"options": {
|
||||
"command": "pnpm --filter web-langraph-interrupt build"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "nx:run-commands",
|
||||
"options": {
|
||||
"command": "pnpm --filter web-langraph-interrupt lint"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": []
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
CopilotRuntime,
|
||||
ExperimentalEmptyAdapter,
|
||||
copilotRuntimeNextJSAppRouterEndpoint,
|
||||
} from "@copilotkit/runtime";
|
||||
import { LangGraphAgent } from "@copilotkit/runtime/langgraph";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
// 1. You can use any service adapter here for multi-agent support. We use
|
||||
// the empty adapter since we're only using one agent.
|
||||
const serviceAdapter = new ExperimentalEmptyAdapter();
|
||||
const agent = new LangGraphAgent({
|
||||
deploymentUrl:
|
||||
process.env.LANGGRAPH_DEPLOYMENT_URL || "http://localhost:8125",
|
||||
graphId: "default",
|
||||
langsmithApiKey: process.env.LANGSMITH_API_KEY || "",
|
||||
});
|
||||
// 2. Create the CopilotRuntime instance and utilize the LangGraph AG-UI
|
||||
// integration to setup the connection.
|
||||
const runtime = new CopilotRuntime({
|
||||
agents: {
|
||||
default: agent,
|
||||
starterAgent: agent,
|
||||
},
|
||||
});
|
||||
|
||||
// 3. Build a Next.js API route that handles the CopilotKit runtime requests.
|
||||
export const POST = async (req: NextRequest) => {
|
||||
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
|
||||
runtime,
|
||||
serviceAdapter,
|
||||
endpoint: "/api/copilotkit",
|
||||
});
|
||||
|
||||
return handleRequest(req);
|
||||
};
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,24 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
body,
|
||||
html {
|
||||
height: 100%;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import { CopilotKit } from "@copilotkit/react-core";
|
||||
import "./globals.css";
|
||||
import "@copilotkit/react-ui/styles.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={"antialiased"}>
|
||||
<CopilotKit runtimeUrl="/api/copilotkit" agent="default">
|
||||
{children}
|
||||
</CopilotKit>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
"use client";
|
||||
|
||||
import { useCoAgent, useCopilotAction } from "@copilotkit/react-core";
|
||||
import { CopilotKitCSSProperties, CopilotSidebar } from "@copilotkit/react-ui";
|
||||
import { useInterrupt } from "@copilotkit/react-core/v2";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function CopilotKitPage() {
|
||||
const [themeColor, setThemeColor] = useState("#6366f1");
|
||||
|
||||
// 🪁 Frontend Actions: https://docs.copilotkit.ai/guides/frontend-actions
|
||||
useCopilotAction({
|
||||
name: "setThemeColor",
|
||||
description: "Set the theme color of the page.",
|
||||
parameters: [
|
||||
{
|
||||
name: "themeColor",
|
||||
description: "The theme color to set. Make sure to pick nice colors.",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
handler({ themeColor }) {
|
||||
setThemeColor(themeColor);
|
||||
},
|
||||
});
|
||||
|
||||
// 🪁 Interrupts: Handle human-in-the-loop confirmations from the agent
|
||||
useInterrupt({
|
||||
render: ({ event, resolve }) => {
|
||||
const { message, proverb, action } = event.value as {
|
||||
message: string;
|
||||
proverb: string;
|
||||
action: string;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 my-2">
|
||||
<p className="text-sm font-medium text-yellow-800 mb-1">
|
||||
Confirmation Required
|
||||
</p>
|
||||
<p className="text-sm text-yellow-700 mb-3">{message}</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => resolve({ approved: true })}
|
||||
className="px-3 py-1.5 text-sm font-medium text-white bg-red-500 hover:bg-red-600 rounded-md transition-colors"
|
||||
>
|
||||
Yes, delete it
|
||||
</button>
|
||||
<button
|
||||
onClick={() => resolve({ approved: false })}
|
||||
className="px-3 py-1.5 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<main
|
||||
style={
|
||||
{ "--copilot-kit-primary-color": themeColor } as CopilotKitCSSProperties
|
||||
}
|
||||
>
|
||||
<YourMainContent themeColor={themeColor} />
|
||||
<CopilotSidebar
|
||||
clickOutsideToClose={false}
|
||||
defaultOpen={true}
|
||||
labels={{
|
||||
title: "Popup Assistant",
|
||||
initial:
|
||||
'👋 Hi, there! You\'re chatting with an agent. This agent comes with a few tools to get you started.\n\nFor example you can try:\n- **Frontend Tools**: "Set the theme to orange"\n- **Shared State**: "Write a proverb about AI"\n- **Generative UI**: "Get the weather in SF"\n- **Interrupts**: "Delete the first proverb" (will ask for confirmation)\n\nAs you interact with the agent, you\'ll see the UI update in real-time to reflect the agent\'s **state**, **tool calls**, and **progress**.',
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// State of the agent, make sure this aligns with your agent's state.
|
||||
type AgentState = {
|
||||
proverbs: string[];
|
||||
};
|
||||
|
||||
function YourMainContent({ themeColor }: { themeColor: string }) {
|
||||
// 🪁 Shared State: https://docs.copilotkit.ai/coagents/shared-state
|
||||
const { state, setState } = useCoAgent<AgentState>({
|
||||
name: "default",
|
||||
initialState: {
|
||||
proverbs: [
|
||||
"CopilotKit may be new, but its the best thing since sliced bread.",
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// 🪁 Frontend Actions: https://docs.copilotkit.ai/coagents/frontend-actions
|
||||
useCopilotAction(
|
||||
{
|
||||
name: "addProverb",
|
||||
description: "Add a proverb to the list.",
|
||||
parameters: [
|
||||
{
|
||||
name: "proverb",
|
||||
description: "The proverb to add. Make it witty, short and concise.",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
handler: ({ proverb }) => {
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
proverbs: [...(prevState?.proverbs || []), proverb],
|
||||
}));
|
||||
},
|
||||
},
|
||||
[setState],
|
||||
);
|
||||
|
||||
//🪁 Generative UI: https://docs.copilotkit.ai/coagents/generative-ui
|
||||
useCopilotAction({
|
||||
name: "getWeather",
|
||||
description: "Get the weather for a given location.",
|
||||
available: "disabled",
|
||||
parameters: [{ name: "location", type: "string", required: true }],
|
||||
render: ({ args }) => {
|
||||
return <WeatherCard location={args.location} themeColor={themeColor} />;
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ backgroundColor: themeColor }}
|
||||
className="h-screen w-screen flex justify-center items-center flex-col transition-colors duration-300"
|
||||
>
|
||||
<div className="bg-white/20 backdrop-blur-md p-8 rounded-2xl shadow-xl max-w-2xl w-full">
|
||||
<h1 className="text-4xl font-bold text-white mb-2 text-center">
|
||||
Proverbs
|
||||
</h1>
|
||||
<p className="text-gray-200 text-center italic mb-6">
|
||||
This is a demonstrative page, but it could be anything you want! 🪁
|
||||
</p>
|
||||
<hr className="border-white/20 my-6" />
|
||||
<div className="flex flex-col gap-3">
|
||||
{state.proverbs?.map((proverb, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="bg-white/15 p-4 rounded-xl text-white relative group hover:bg-white/20 transition-all"
|
||||
>
|
||||
<p className="pr-8">{proverb}</p>
|
||||
<button
|
||||
onClick={() =>
|
||||
setState({
|
||||
...state,
|
||||
proverbs: state.proverbs?.filter((_, i) => i !== index),
|
||||
})
|
||||
}
|
||||
className="absolute right-3 top-3 opacity-0 group-hover:opacity-100 transition-opacity
|
||||
bg-red-500 hover:bg-red-600 text-white rounded-full h-6 w-6 flex items-center justify-center"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{state.proverbs?.length === 0 && (
|
||||
<p className="text-center text-white/80 italic my-8">
|
||||
No proverbs yet. Ask the assistant to add some!
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Simple sun icon for the weather card
|
||||
function SunIcon() {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="w-14 h-14 text-yellow-200"
|
||||
>
|
||||
<circle cx="12" cy="12" r="5" />
|
||||
<path
|
||||
d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"
|
||||
strokeWidth="2"
|
||||
stroke="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// Weather card component where the location and themeColor are based on what the agent
|
||||
// sets via tool calls.
|
||||
function WeatherCard({
|
||||
location,
|
||||
themeColor,
|
||||
}: {
|
||||
location?: string;
|
||||
themeColor: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{ backgroundColor: themeColor }}
|
||||
className="rounded-xl shadow-xl mt-6 mb-4 max-w-md w-full"
|
||||
>
|
||||
<div className="bg-white/20 p-4 w-full">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-white capitalize">
|
||||
{location}
|
||||
</h3>
|
||||
<p className="text-white">Current Weather</p>
|
||||
</div>
|
||||
<SunIcon />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-end justify-between">
|
||||
<div className="text-3xl font-bold text-white">70°</div>
|
||||
<div className="text-sm text-white">Clear skies</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-white">
|
||||
<div className="grid grid-cols-3 gap-2 text-center">
|
||||
<div>
|
||||
<p className="text-white text-xs">Humidity</p>
|
||||
<p className="text-white font-medium">45%</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white text-xs">Wind</p>
|
||||
<p className="text-white font-medium">5 mph</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-white text-xs">Feels Like</p>
|
||||
<p className="text-white font-medium">72°</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
|
||||
|
||||
const eslintConfig = [
|
||||
...nextCoreWebVitals,
|
||||
{
|
||||
ignores: [
|
||||
"node_modules/**",
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "langgraph-js-starter",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"apps/*"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "turbo run dev",
|
||||
"build": "turbo run build",
|
||||
"lint": "turbo run lint"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@langchain/langgraph-cli": "^1.0.4",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-next": "^16.0.8",
|
||||
"turbo": "^2.3.3"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.0",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@langchain/core": "^1.0.1",
|
||||
"@langchain/langgraph": "1.0.2"
|
||||
}
|
||||
}
|
||||
@@ -106,9 +106,6 @@
|
||||
"prismjs@<=1.30.0": "1.30.0",
|
||||
"pino@<=10.1.1": "10.1.1"
|
||||
},
|
||||
"publicHoistPattern": [
|
||||
"@ag-ui/*"
|
||||
],
|
||||
"patchedDependencies": {
|
||||
"@changesets/assemble-release-plan@6.0.9": "patches/@changesets__assemble-release-plan@6.0.9.patch"
|
||||
}
|
||||
|
||||
@@ -49,6 +49,8 @@ vi.mock("@copilotkitnext/react", () => ({
|
||||
clearSuggestions: vi.fn(),
|
||||
addSuggestionsConfig: vi.fn(),
|
||||
reloadSuggestions: vi.fn(),
|
||||
interruptElement: null,
|
||||
subscribe: vi.fn(() => ({ unsubscribe: vi.fn() })),
|
||||
},
|
||||
})),
|
||||
useCopilotChatConfiguration: vi.fn(() => ({ agentId: "test-agent" })),
|
||||
@@ -67,10 +69,6 @@ vi.mock("../../components/error-boundary/error-utils", () => ({
|
||||
useAsyncCallback: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
}));
|
||||
|
||||
vi.mock("../use-langgraph-interrupt-render", () => ({
|
||||
useLangGraphInterruptRender: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock("../use-lazy-tool-renderer", () => ({
|
||||
useLazyToolRenderer: vi.fn(() => () => null),
|
||||
}));
|
||||
|
||||
@@ -20,7 +20,6 @@ export { useCoAgent, type HintFunction } from "./use-coagent";
|
||||
export { useCopilotRuntimeClient } from "./use-copilot-runtime-client";
|
||||
export { useCopilotAuthenticatedAction_c } from "./use-copilot-authenticated-action";
|
||||
export { useLangGraphInterrupt } from "./use-langgraph-interrupt";
|
||||
export { useLangGraphInterruptRender } from "./use-langgraph-interrupt-render";
|
||||
export { useCopilotAdditionalInstructions } from "./use-copilot-additional-instructions";
|
||||
export type { Tree, TreeNode } from "./use-tree";
|
||||
export { useFrontendTool } from "./use-frontend-tool";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {
|
||||
import React, {
|
||||
useRef,
|
||||
useEffect,
|
||||
useCallback,
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
gqlToAGUI,
|
||||
Message as DeprecatedGqlMessage,
|
||||
} from "@copilotkit/runtime-client-gql";
|
||||
import { useLangGraphInterruptRender } from "./use-langgraph-interrupt-render";
|
||||
import {
|
||||
useAgent,
|
||||
useCopilotChatConfiguration,
|
||||
@@ -363,7 +362,19 @@ export function useCopilotChatInternal({
|
||||
onInProgress?.(Boolean(agent?.isRunning));
|
||||
}, [agent?.isRunning, onInProgress]);
|
||||
|
||||
const interrupt = useLangGraphInterruptRender(agent);
|
||||
// Subscribe to copilotkit.interruptElement so the v1 return type stays
|
||||
// reactive. The element is published by useInterrupt (v2) when user code
|
||||
// calls useLangGraphInterrupt({ render, ... }).
|
||||
const [interrupt, setInterrupt] = useState<React.ReactElement | null>(null);
|
||||
useEffect(() => {
|
||||
setInterrupt(copilotkit.interruptElement);
|
||||
const subscription = copilotkit.subscribe({
|
||||
onInterruptElementChanged: ({ interruptElement }) => {
|
||||
setInterrupt(interruptElement);
|
||||
},
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, [copilotkit]);
|
||||
|
||||
const reset = () => {
|
||||
agent?.setMessages([]);
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
import { useCopilotContext } from "../context";
|
||||
import React, { useCallback, useEffect, useMemo } from "react";
|
||||
import type { AbstractAgent, AgentSubscriber } from "@ag-ui/client";
|
||||
import { MetaEventName } from "@copilotkit/runtime-client-gql";
|
||||
import { dataToUUID, parseJson } from "@copilotkit/shared";
|
||||
import { useAgentNodeName } from "./use-agent-nodename";
|
||||
import { useCopilotChatConfiguration } from "@copilotkitnext/react";
|
||||
|
||||
type InterruptProps = {
|
||||
event: any;
|
||||
result: any;
|
||||
render: (props: {
|
||||
event: any;
|
||||
result: any;
|
||||
resolve: (response: string) => void;
|
||||
}) => string | React.ReactElement;
|
||||
resolve: (response: string) => void;
|
||||
};
|
||||
|
||||
const InterruptRenderer: React.FC<InterruptProps> = ({
|
||||
event,
|
||||
result,
|
||||
render,
|
||||
resolve,
|
||||
}) => {
|
||||
return render({ event, result, resolve });
|
||||
};
|
||||
|
||||
export function useLangGraphInterruptRender(
|
||||
agent: AbstractAgent,
|
||||
): string | React.ReactElement | null {
|
||||
const {
|
||||
interruptActions,
|
||||
agentSession,
|
||||
threadId,
|
||||
interruptEventQueue,
|
||||
addInterruptEvent,
|
||||
resolveInterruptEvent,
|
||||
} = useCopilotContext();
|
||||
const existingConfig = useCopilotChatConfiguration();
|
||||
const resolvedAgentId = existingConfig?.agentId ?? "default";
|
||||
const nodeName = useAgentNodeName(resolvedAgentId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!agent) return;
|
||||
let localInterrupt: any = null;
|
||||
const subscriber: AgentSubscriber = {
|
||||
onCustomEvent: ({ event }) => {
|
||||
if (event.name === "on_interrupt") {
|
||||
const eventData = {
|
||||
name: MetaEventName.LangGraphInterruptEvent,
|
||||
type: event.type,
|
||||
value: parseJson(event.value, event.value),
|
||||
};
|
||||
const eventId = dataToUUID(eventData, "interruptEvents");
|
||||
localInterrupt = {
|
||||
eventId,
|
||||
threadId,
|
||||
event: eventData,
|
||||
};
|
||||
}
|
||||
},
|
||||
onRunStartedEvent: () => {
|
||||
localInterrupt = null;
|
||||
},
|
||||
onRunFinalized: () => {
|
||||
if (localInterrupt) {
|
||||
addInterruptEvent(localInterrupt);
|
||||
localInterrupt = null;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const { unsubscribe } = agent.subscribe(subscriber);
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [agent, threadId]);
|
||||
|
||||
const handleResolve = useCallback(
|
||||
(eventId: string, response?: string) => {
|
||||
agent?.runAgent({
|
||||
forwardedProps: {
|
||||
command: {
|
||||
resume: response,
|
||||
},
|
||||
},
|
||||
});
|
||||
resolveInterruptEvent(threadId, eventId, response ?? "");
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[agent, threadId],
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
// Get the queue for this thread and find the first unresponded event
|
||||
const eventQueue = interruptEventQueue[threadId] || [];
|
||||
const currentQueuedEvent = eventQueue.find((qe) => !qe.event.response);
|
||||
|
||||
if (!currentQueuedEvent || !agentSession) return null;
|
||||
|
||||
// Find the first matching action from all registered actions
|
||||
const allActions = Object.values(interruptActions);
|
||||
const matchingAction = allActions.find((action) => {
|
||||
if (!action.enabled) return true; // No filter = match all
|
||||
return action.enabled({
|
||||
eventValue: currentQueuedEvent.event.value,
|
||||
agentMetadata: {
|
||||
...agentSession,
|
||||
nodeName,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
if (!matchingAction) return null;
|
||||
|
||||
const { render, handler } = matchingAction;
|
||||
|
||||
const resolveInterrupt = (response: string) => {
|
||||
handleResolve(currentQueuedEvent.eventId, response);
|
||||
};
|
||||
|
||||
let result = null;
|
||||
if (handler) {
|
||||
result = handler({
|
||||
event: currentQueuedEvent.event,
|
||||
resolve: resolveInterrupt,
|
||||
});
|
||||
}
|
||||
|
||||
if (!render) return null;
|
||||
|
||||
return React.createElement(InterruptRenderer, {
|
||||
event: currentQueuedEvent.event,
|
||||
result,
|
||||
render,
|
||||
resolve: resolveInterrupt,
|
||||
});
|
||||
}, [
|
||||
interruptActions,
|
||||
interruptEventQueue,
|
||||
threadId,
|
||||
agentSession,
|
||||
handleResolve,
|
||||
]);
|
||||
}
|
||||
@@ -1,46 +1,105 @@
|
||||
import { useContext, useEffect, useMemo } from "react";
|
||||
import { CopilotContext } from "../context/copilot-context";
|
||||
import React, { useCallback, useRef } from "react";
|
||||
import { LangGraphInterruptRender } from "../types/interrupt-action";
|
||||
import { useToast } from "../components/toast/toast-provider";
|
||||
import { dataToUUID } from "@copilotkit/shared";
|
||||
import {
|
||||
useInterrupt,
|
||||
useCopilotChatConfiguration,
|
||||
} from "@copilotkitnext/react";
|
||||
import type {
|
||||
InterruptEvent,
|
||||
InterruptRenderProps,
|
||||
InterruptHandlerProps,
|
||||
} from "@copilotkitnext/react";
|
||||
import { MetaEventName } from "@copilotkit/runtime-client-gql";
|
||||
import { parseJson } from "@copilotkit/shared";
|
||||
import { useAgentNodeName } from "./use-agent-nodename";
|
||||
import type { AgentSession } from "../context/copilot-context";
|
||||
|
||||
/**
|
||||
* Transforms a v2 InterruptEvent into the v1 LangGraphInterruptEvent shape
|
||||
* expected by existing useLangGraphInterrupt callbacks.
|
||||
*/
|
||||
function toV1Event<TEventValue>(event: InterruptEvent<TEventValue>) {
|
||||
const value =
|
||||
typeof event.value === "string"
|
||||
? parseJson(event.value, event.value)
|
||||
: event.value;
|
||||
return {
|
||||
name: MetaEventName.LangGraphInterruptEvent,
|
||||
type: "MetaEvent" as const,
|
||||
value,
|
||||
};
|
||||
}
|
||||
|
||||
export function useLangGraphInterrupt<TEventValue = any>(
|
||||
action: Omit<LangGraphInterruptRender<TEventValue>, "id">,
|
||||
dependencies?: any[],
|
||||
_dependencies?: any[],
|
||||
) {
|
||||
const {
|
||||
setInterruptAction,
|
||||
removeInterruptAction,
|
||||
interruptActions,
|
||||
const actionRef = useRef(action);
|
||||
// Update ref synchronously during render so it's always current
|
||||
// when callbacks read from it (useEffect would be one tick late).
|
||||
actionRef.current = action;
|
||||
|
||||
const existingConfig = useCopilotChatConfiguration();
|
||||
const resolvedAgentId = existingConfig?.agentId ?? "default";
|
||||
const threadId = existingConfig?.threadId;
|
||||
const nodeName = useAgentNodeName(resolvedAgentId);
|
||||
|
||||
// Keep agentMetadata in a ref so stable callbacks always see current values.
|
||||
const metadataRef = useRef<AgentSession>({
|
||||
agentName: resolvedAgentId,
|
||||
threadId,
|
||||
} = useContext(CopilotContext);
|
||||
const { addToast } = useToast();
|
||||
|
||||
const actionId = dataToUUID(action, "lgAction");
|
||||
|
||||
useEffect(() => {
|
||||
if (!action) return;
|
||||
|
||||
// if (!action.enabled) {
|
||||
// TODO: if there are any other actions registered, we need to warn the user that a current action without "enabled" might render for everything
|
||||
// addToast({
|
||||
// type: "warning",
|
||||
// message: "An action is already registered for the interrupt event",
|
||||
// });
|
||||
// return;
|
||||
// }
|
||||
|
||||
setInterruptAction({ ...action, id: actionId });
|
||||
|
||||
// Cleanup: remove action on unmount
|
||||
return () => {
|
||||
removeInterruptAction(actionId);
|
||||
};
|
||||
}, [
|
||||
setInterruptAction,
|
||||
removeInterruptAction,
|
||||
nodeName,
|
||||
});
|
||||
metadataRef.current = {
|
||||
agentName: resolvedAgentId,
|
||||
threadId,
|
||||
actionId,
|
||||
...(dependencies || []),
|
||||
]);
|
||||
nodeName,
|
||||
};
|
||||
|
||||
// Stable callback references that always read the latest action from the ref.
|
||||
// This prevents useInterrupt's internal useMemo/useEffect from seeing new
|
||||
// function identities on every render, which would cause an infinite loop.
|
||||
const render = useCallback(
|
||||
({ event, result, resolve }: InterruptRenderProps<TEventValue>) => {
|
||||
const renderFn = actionRef.current.render;
|
||||
if (!renderFn) return React.createElement(React.Fragment);
|
||||
const rendered = renderFn({
|
||||
event: toV1Event(event) as any,
|
||||
result,
|
||||
resolve: (r) => resolve(r),
|
||||
});
|
||||
if (typeof rendered === "string") {
|
||||
return React.createElement(React.Fragment, null, rendered);
|
||||
}
|
||||
return rendered;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Handler always delegates to the ref — if no handler is set at call time,
|
||||
// the optional chaining returns undefined which useInterrupt treats as null.
|
||||
const handler = useCallback(
|
||||
({ event, resolve }: InterruptHandlerProps<TEventValue>) => {
|
||||
return actionRef.current.handler?.({
|
||||
event: toV1Event(event) as any,
|
||||
resolve: (r) => resolve(r),
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const enabled = useCallback((event: InterruptEvent<TEventValue>) => {
|
||||
if (!actionRef.current.enabled) return true;
|
||||
return actionRef.current.enabled({
|
||||
eventValue: toV1Event(event).value,
|
||||
agentMetadata: metadataRef.current,
|
||||
});
|
||||
}, []);
|
||||
|
||||
useInterrupt({
|
||||
render,
|
||||
handler,
|
||||
enabled,
|
||||
agentId: resolvedAgentId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { FrontendTool } from "../types";
|
||||
|
||||
export interface CopilotKitCoreRunAgentParams {
|
||||
agent: AbstractAgent;
|
||||
forwardedProps?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CopilotKitCoreConnectAgentParams {
|
||||
@@ -162,6 +163,7 @@ export class RunHandler {
|
||||
*/
|
||||
async runAgent({
|
||||
agent,
|
||||
forwardedProps,
|
||||
}: CopilotKitCoreRunAgentParams): Promise<RunAgentResult> {
|
||||
// Agent ID is guaranteed to be set by validateAndAssignAgentId
|
||||
if (agent.agentId) {
|
||||
@@ -179,8 +181,10 @@ export class RunHandler {
|
||||
try {
|
||||
const runAgentResult = await agent.runAgent(
|
||||
{
|
||||
forwardedProps: (this.core as unknown as CopilotKitCoreFriendsAccess)
|
||||
.properties,
|
||||
forwardedProps: {
|
||||
...(this.core as unknown as CopilotKitCoreFriendsAccess).properties,
|
||||
...forwardedProps,
|
||||
},
|
||||
tools: this.buildFrontendTools(agent.agentId),
|
||||
context: Object.values(
|
||||
(this.core as unknown as CopilotKitCoreFriendsAccess).context,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useReducer } from "react";
|
||||
import React, { useEffect, useReducer, useState } from "react";
|
||||
import { WithSlots, renderSlot, isReactComponentType } from "@/lib/slots";
|
||||
import CopilotChatAssistantMessage from "./CopilotChatAssistantMessage";
|
||||
import CopilotChatUserMessage from "./CopilotChatUserMessage";
|
||||
@@ -289,6 +289,7 @@ export type CopilotChatMessageViewProps = Omit<
|
||||
isRunning: boolean;
|
||||
messages: Message[];
|
||||
messageElements: React.ReactElement[];
|
||||
interruptElement: React.ReactElement | null;
|
||||
}) => React.ReactElement;
|
||||
};
|
||||
|
||||
@@ -321,6 +322,19 @@ export function CopilotChatMessageView({
|
||||
return () => subscription.unsubscribe();
|
||||
}, [config?.agentId, copilotkit, forceUpdate]);
|
||||
|
||||
// Subscribe to interrupt element changes for in-chat rendering.
|
||||
const [interruptElement, setInterruptElement] =
|
||||
useState<React.ReactElement | null>(null);
|
||||
useEffect(() => {
|
||||
setInterruptElement(copilotkit.interruptElement);
|
||||
const subscription = copilotkit.subscribe({
|
||||
onInterruptElementChanged: ({ interruptElement }) => {
|
||||
setInterruptElement(interruptElement);
|
||||
},
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, [copilotkit]);
|
||||
|
||||
// Helper to get state snapshot for a message (used for memoization)
|
||||
const getStateSnapshotForMessage = (messageId: string): unknown => {
|
||||
if (!config) return undefined;
|
||||
@@ -479,7 +493,7 @@ export function CopilotChatMessageView({
|
||||
if (children) {
|
||||
return (
|
||||
<div data-copilotkit style={{ display: "contents" }}>
|
||||
{children({ messageElements, messages, isRunning })}
|
||||
{children({ messageElements, messages, isRunning, interruptElement })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -496,6 +510,7 @@ export function CopilotChatMessageView({
|
||||
{...props}
|
||||
>
|
||||
{messageElements}
|
||||
{interruptElement}
|
||||
{showCursor && (
|
||||
<div className="cpk:mt-2">
|
||||
{renderSlot(cursor, CopilotChatMessageView.Cursor, {})}
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
import React from "react";
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useInterrupt } from "../use-interrupt";
|
||||
import { useCopilotKit } from "@/providers/CopilotKitProvider";
|
||||
import { useAgent } from "../use-agent";
|
||||
|
||||
vi.mock("@/providers/CopilotKitProvider", () => ({
|
||||
useCopilotKit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../use-agent", () => ({
|
||||
useAgent: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockUseCopilotKit = useCopilotKit as ReturnType<typeof vi.fn>;
|
||||
const mockUseAgent = useAgent as ReturnType<typeof vi.fn>;
|
||||
|
||||
type SubscriptionHandlers = {
|
||||
onCustomEvent?: (payload: {
|
||||
event: { name: string; value: unknown };
|
||||
}) => void;
|
||||
onRunStartedEvent?: () => void;
|
||||
onRunFinalized?: () => void;
|
||||
onRunFailed?: () => void;
|
||||
};
|
||||
|
||||
describe("useInterrupt", () => {
|
||||
let runAgentMock: ReturnType<typeof vi.fn>;
|
||||
let setInterruptElementMock: ReturnType<typeof vi.fn>;
|
||||
let unsubscribeMock: ReturnType<typeof vi.fn>;
|
||||
let subscribeMock: ReturnType<typeof vi.fn>;
|
||||
let handlers: SubscriptionHandlers;
|
||||
let mockAgent: Record<string, unknown>;
|
||||
|
||||
beforeEach(() => {
|
||||
runAgentMock = vi.fn();
|
||||
setInterruptElementMock = vi.fn();
|
||||
unsubscribeMock = vi.fn();
|
||||
|
||||
handlers = {};
|
||||
subscribeMock = vi.fn((nextHandlers: SubscriptionHandlers) => {
|
||||
handlers = nextHandlers;
|
||||
return { unsubscribe: unsubscribeMock };
|
||||
});
|
||||
|
||||
mockAgent = {
|
||||
subscribe: subscribeMock,
|
||||
id: "test-agent",
|
||||
};
|
||||
|
||||
mockUseCopilotKit.mockReturnValue({
|
||||
copilotkit: {
|
||||
runAgent: runAgentMock,
|
||||
setInterruptElement: setInterruptElementMock,
|
||||
},
|
||||
});
|
||||
|
||||
mockUseAgent.mockReturnValue({ agent: mockAgent });
|
||||
});
|
||||
|
||||
function Harness({
|
||||
enabled,
|
||||
handler,
|
||||
renderInChat,
|
||||
renderSpy,
|
||||
}: {
|
||||
enabled?: (event: { name: string; value: unknown }) => boolean;
|
||||
handler?: (props: {
|
||||
event: { name: string; value: unknown };
|
||||
resolve: (response: unknown) => void;
|
||||
}) => unknown;
|
||||
renderInChat?: boolean;
|
||||
renderSpy?: ReturnType<typeof vi.fn>;
|
||||
}) {
|
||||
const renderInterrupt = ({
|
||||
event,
|
||||
result,
|
||||
resolve,
|
||||
}: {
|
||||
event: { name: string; value: unknown };
|
||||
result: unknown;
|
||||
resolve: (response: unknown) => void;
|
||||
}) => {
|
||||
renderSpy?.({ event, result, resolve });
|
||||
return (
|
||||
<button
|
||||
data-testid="interrupt"
|
||||
onClick={() => resolve({ approved: true, value: event.value })}
|
||||
>
|
||||
{String(result ?? "no-result")}:{String(event.value)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
if (renderInChat === false) {
|
||||
return (
|
||||
<ManualHarness
|
||||
enabled={enabled}
|
||||
handler={handler}
|
||||
render={renderInterrupt}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChatHarness
|
||||
enabled={enabled}
|
||||
handler={handler}
|
||||
render={renderInterrupt}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ManualHarness({
|
||||
enabled,
|
||||
handler,
|
||||
render,
|
||||
}: {
|
||||
enabled?: (event: { name: string; value: unknown }) => boolean;
|
||||
handler?: (props: {
|
||||
event: { name: string; value: unknown };
|
||||
resolve: (response: unknown) => void;
|
||||
}) => unknown;
|
||||
render: (props: {
|
||||
event: { name: string; value: unknown };
|
||||
result: unknown;
|
||||
resolve: (response: unknown) => void;
|
||||
}) => React.ReactElement;
|
||||
}) {
|
||||
const element = useInterrupt({
|
||||
enabled,
|
||||
handler,
|
||||
renderInChat: false,
|
||||
render,
|
||||
});
|
||||
|
||||
return <div data-testid="manual-container">{element}</div>;
|
||||
}
|
||||
|
||||
function ChatHarness({
|
||||
enabled,
|
||||
handler,
|
||||
render,
|
||||
}: {
|
||||
enabled?: (event: { name: string; value: unknown }) => boolean;
|
||||
handler?: (props: {
|
||||
event: { name: string; value: unknown };
|
||||
resolve: (response: unknown) => void;
|
||||
}) => unknown;
|
||||
render: (props: {
|
||||
event: { name: string; value: unknown };
|
||||
result: unknown;
|
||||
resolve: (response: unknown) => void;
|
||||
}) => React.ReactElement;
|
||||
}) {
|
||||
useInterrupt({
|
||||
enabled,
|
||||
handler,
|
||||
render,
|
||||
});
|
||||
|
||||
return <div data-testid="manual-container" />;
|
||||
}
|
||||
|
||||
function emitInterrupt(value: unknown) {
|
||||
act(() => {
|
||||
handlers.onCustomEvent?.({
|
||||
event: { name: "on_interrupt", value },
|
||||
});
|
||||
handlers.onRunFinalized?.();
|
||||
});
|
||||
}
|
||||
|
||||
it("subscribes on mount and unsubscribes on unmount", () => {
|
||||
const { unmount } = render(<Harness renderInChat={false} />);
|
||||
|
||||
expect(subscribeMock).toHaveBeenCalledTimes(1);
|
||||
expect(handlers.onCustomEvent).toBeTypeOf("function");
|
||||
expect(handlers.onRunStartedEvent).toBeTypeOf("function");
|
||||
expect(handlers.onRunFinalized).toBeTypeOf("function");
|
||||
expect(handlers.onRunFailed).toBeTypeOf("function");
|
||||
|
||||
unmount();
|
||||
expect(unsubscribeMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("ignores non-interrupt custom events", () => {
|
||||
render(<Harness renderInChat={false} />);
|
||||
|
||||
act(() => {
|
||||
handlers.onCustomEvent?.({
|
||||
event: { name: "not_interrupt", value: "x" },
|
||||
});
|
||||
handlers.onRunFinalized?.();
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("interrupt")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders interrupt only after run finalized", () => {
|
||||
render(<Harness renderInChat={false} />);
|
||||
|
||||
act(() => {
|
||||
handlers.onCustomEvent?.({
|
||||
event: { name: "on_interrupt", value: "pending" },
|
||||
});
|
||||
});
|
||||
expect(screen.queryByTestId("interrupt")).toBeNull();
|
||||
|
||||
act(() => {
|
||||
handlers.onRunFinalized?.();
|
||||
});
|
||||
expect(screen.getByTestId("interrupt").textContent).toContain("pending");
|
||||
});
|
||||
|
||||
it("clears pending interrupt on run start", () => {
|
||||
render(<Harness renderInChat={false} />);
|
||||
|
||||
emitInterrupt("first");
|
||||
expect(screen.getByTestId("interrupt").textContent).toContain("first");
|
||||
|
||||
act(() => {
|
||||
handlers.onRunStartedEvent?.();
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("interrupt")).toBeNull();
|
||||
});
|
||||
|
||||
it("resolve clears UI and resumes agent with response payload", () => {
|
||||
render(<Harness renderInChat={false} />);
|
||||
|
||||
emitInterrupt("approve-me");
|
||||
act(() => {
|
||||
screen.getByTestId("interrupt").click();
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("interrupt")).toBeNull();
|
||||
expect(runAgentMock).toHaveBeenCalledTimes(1);
|
||||
expect(runAgentMock).toHaveBeenCalledWith({
|
||||
agent: mockAgent,
|
||||
forwardedProps: {
|
||||
command: {
|
||||
resume: { approved: true, value: "approve-me" },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not render and does not run handler when enabled returns false", () => {
|
||||
const handler = vi.fn(() => "should-not-run");
|
||||
render(
|
||||
<Harness renderInChat={false} enabled={() => false} handler={handler} />,
|
||||
);
|
||||
|
||||
emitInterrupt("blocked");
|
||||
|
||||
expect(screen.queryByTestId("interrupt")).toBeNull();
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders with null result when no handler is provided", () => {
|
||||
render(<Harness renderInChat={false} />);
|
||||
|
||||
emitInterrupt("no-handler");
|
||||
|
||||
expect(screen.getByTestId("interrupt").textContent).toContain(
|
||||
"no-result:no-handler",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses sync handler result in render", () => {
|
||||
render(
|
||||
<Harness
|
||||
renderInChat={false}
|
||||
handler={({ event }) => `handled:${String(event.value)}`}
|
||||
/>,
|
||||
);
|
||||
|
||||
emitInterrupt("sync");
|
||||
|
||||
expect(screen.getByTestId("interrupt").textContent).toContain(
|
||||
"handled:sync",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses async handler resolved value in render", async () => {
|
||||
render(
|
||||
<Harness
|
||||
renderInChat={false}
|
||||
handler={({ event }) => Promise.resolve(`async:${String(event.value)}`)}
|
||||
/>,
|
||||
);
|
||||
|
||||
emitInterrupt("value");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("interrupt").textContent).toContain(
|
||||
"async:value",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to null result when async handler rejects", async () => {
|
||||
render(
|
||||
<Harness
|
||||
renderInChat={false}
|
||||
handler={() => Promise.reject(new Error("boom"))}
|
||||
/>,
|
||||
);
|
||||
|
||||
emitInterrupt("reject");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("interrupt").textContent).toContain(
|
||||
"no-result:reject",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts thenable handler results (non-native Promise)", async () => {
|
||||
const thenable = {
|
||||
then: (resolve: (value: string) => void) => {
|
||||
resolve("thenable-ok");
|
||||
return { catch: () => undefined };
|
||||
},
|
||||
};
|
||||
|
||||
render(<Harness renderInChat={false} handler={() => thenable} />);
|
||||
|
||||
emitInterrupt("thenable");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("interrupt").textContent).toContain(
|
||||
"thenable-ok:thenable",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("publishes interrupt element to chat by default and clears on unmount", async () => {
|
||||
const renderSpy = vi.fn();
|
||||
const { unmount } = render(<Harness renderSpy={renderSpy} />);
|
||||
|
||||
emitInterrupt("chat");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(renderSpy).toHaveBeenCalled();
|
||||
expect(setInterruptElementMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const latestCallArg = setInterruptElementMock.mock.calls.at(-1)?.[0];
|
||||
expect(React.isValidElement(latestCallArg)).toBe(true);
|
||||
|
||||
unmount();
|
||||
expect(setInterruptElementMock.mock.calls.at(-1)?.[0]).toBeNull();
|
||||
});
|
||||
|
||||
it("does not publish to chat and returns manual element when renderInChat is false", () => {
|
||||
render(<Harness renderInChat={false} />);
|
||||
|
||||
emitInterrupt("manual");
|
||||
|
||||
expect(screen.getByTestId("interrupt").textContent).toContain("manual");
|
||||
expect(setInterruptElementMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("discards local interrupt when run fails before finalize", () => {
|
||||
render(<Harness renderInChat={false} />);
|
||||
|
||||
act(() => {
|
||||
handlers.onCustomEvent?.({
|
||||
event: { name: "on_interrupt", value: "lost" },
|
||||
});
|
||||
handlers.onRunFailed?.();
|
||||
handlers.onRunFinalized?.();
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("interrupt")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the latest interrupt when multiple interrupts arrive within one run", () => {
|
||||
render(<Harness renderInChat={false} />);
|
||||
|
||||
act(() => {
|
||||
handlers.onCustomEvent?.({
|
||||
event: { name: "on_interrupt", value: "first" },
|
||||
});
|
||||
handlers.onCustomEvent?.({
|
||||
event: { name: "on_interrupt", value: "second" },
|
||||
});
|
||||
handlers.onRunFinalized?.();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("interrupt").textContent).toContain("second");
|
||||
});
|
||||
});
|
||||
@@ -12,3 +12,5 @@ export { useAgentContext } from "./use-agent-context";
|
||||
export type { AgentContextInput, JsonSerializable } from "./use-agent-context";
|
||||
export { useSuggestions } from "./use-suggestions";
|
||||
export { useConfigureSuggestions } from "./use-configure-suggestions";
|
||||
export { useInterrupt } from "./use-interrupt";
|
||||
export type { UseInterruptConfig } from "./use-interrupt";
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { useCopilotKit } from "@/providers/CopilotKitProvider";
|
||||
import { useAgent } from "./use-agent";
|
||||
import type {
|
||||
InterruptEvent,
|
||||
InterruptRenderProps,
|
||||
InterruptHandlerProps,
|
||||
} from "../types/interrupt";
|
||||
|
||||
export type { InterruptEvent, InterruptRenderProps, InterruptHandlerProps };
|
||||
|
||||
const INTERRUPT_EVENT_NAME = "on_interrupt";
|
||||
|
||||
type InterruptHandlerFn<TValue, TResult> = (
|
||||
props: InterruptHandlerProps<TValue>,
|
||||
) => TResult | PromiseLike<TResult>;
|
||||
|
||||
type InterruptResultFromHandler<THandler> = THandler extends (
|
||||
...args: never[]
|
||||
) => infer TResult
|
||||
? TResult extends PromiseLike<infer TResolved>
|
||||
? TResolved | null
|
||||
: TResult | null
|
||||
: null;
|
||||
|
||||
type InterruptResult<TValue, TResult> = InterruptResultFromHandler<
|
||||
InterruptHandlerFn<TValue, TResult>
|
||||
>;
|
||||
|
||||
type InterruptRenderInChat = boolean | undefined;
|
||||
|
||||
type UseInterruptReturn<TRenderInChat extends InterruptRenderInChat> =
|
||||
TRenderInChat extends false
|
||||
? React.ReactElement | null
|
||||
: TRenderInChat extends true | undefined
|
||||
? void
|
||||
: React.ReactElement | null | void;
|
||||
|
||||
export function isPromiseLike<TValue>(
|
||||
value: TValue | PromiseLike<TValue>,
|
||||
): value is PromiseLike<TValue> {
|
||||
return (
|
||||
(typeof value === "object" || typeof value === "function") &&
|
||||
value !== null &&
|
||||
typeof Reflect.get(value, "then") === "function"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration options for `useInterrupt`.
|
||||
*/
|
||||
interface UseInterruptConfigBase<TValue = unknown, TResult = never> {
|
||||
/**
|
||||
* Render function for the interrupt UI.
|
||||
*
|
||||
* This is called once an interrupt is finalized and accepted by `enabled` (if provided).
|
||||
* Use `resolve` from render props to resume the agent run with user input.
|
||||
*/
|
||||
render: (
|
||||
props: InterruptRenderProps<TValue, InterruptResult<TValue, TResult>>,
|
||||
) => React.ReactElement;
|
||||
/**
|
||||
* Optional pre-render handler invoked when an interrupt is received.
|
||||
*
|
||||
* Return either a sync value or an async value to pass into `render` as `result`.
|
||||
* Rejecting/throwing falls back to `result = null`.
|
||||
*/
|
||||
handler?: InterruptHandlerFn<TValue, TResult>;
|
||||
/**
|
||||
* Optional predicate to filter which interrupts should be handled by this hook.
|
||||
* Return `false` to ignore an interrupt.
|
||||
*/
|
||||
enabled?: (event: InterruptEvent<TValue>) => boolean;
|
||||
/** Optional agent id. Defaults to the current configured chat agent. */
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
export interface UseInterruptInChatConfig<
|
||||
TValue = unknown,
|
||||
TResult = never,
|
||||
> extends UseInterruptConfigBase<TValue, TResult> {
|
||||
/** When true (default), the interrupt UI renders inside `<CopilotChat>` automatically. Set to false to render it yourself. */
|
||||
renderInChat?: true;
|
||||
}
|
||||
|
||||
export interface UseInterruptExternalConfig<
|
||||
TValue = unknown,
|
||||
TResult = never,
|
||||
> extends UseInterruptConfigBase<TValue, TResult> {
|
||||
/** When true (default), the interrupt UI renders inside `<CopilotChat>` automatically. Set to false to render it yourself. */
|
||||
renderInChat: false;
|
||||
}
|
||||
|
||||
export interface UseInterruptDynamicConfig<
|
||||
TValue = unknown,
|
||||
TResult = never,
|
||||
> extends UseInterruptConfigBase<TValue, TResult> {
|
||||
/** Dynamic boolean mode. When non-literal, return type is a union. */
|
||||
renderInChat: boolean;
|
||||
}
|
||||
|
||||
export type UseInterruptConfig<
|
||||
TValue = unknown,
|
||||
TResult = never,
|
||||
TRenderInChat extends InterruptRenderInChat = undefined,
|
||||
> = UseInterruptConfigBase<TValue, TResult> & {
|
||||
/** When true (default), the interrupt UI renders inside `<CopilotChat>` automatically. Set to false to render it yourself. */
|
||||
renderInChat?: TRenderInChat;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles agent interrupts (`on_interrupt`) with optional filtering, preprocessing, and resume behavior.
|
||||
*
|
||||
* The hook listens to custom events on the active agent, stores interrupt payloads per run,
|
||||
* and surfaces a render callback once the run finalizes. Call `resolve` from your UI to resume
|
||||
* execution with user-provided data.
|
||||
*
|
||||
* - `renderInChat: true` (default): the element is published into `<CopilotChat>` and this hook returns `void`.
|
||||
* - `renderInChat: false`: the hook returns the interrupt element so you can place it anywhere in your component tree.
|
||||
*
|
||||
* `event.value` is typed as `any` since the interrupt payload shape depends on your agent.
|
||||
* Type-narrow it in your callbacks (e.g. `handler`, `enabled`, `render`) as needed.
|
||||
*
|
||||
* @typeParam TResult - Inferred from `handler` return type. Exposed as `result` in `render`.
|
||||
* @param config - Interrupt configuration (renderer, optional handler/filter, and render mode).
|
||||
* @returns When `renderInChat` is `false`, returns the interrupt element (or `null` when idle).
|
||||
* Otherwise returns `void` and publishes the element into chat. In `render`, `result` is always
|
||||
* either the handler's resolved return value or `null` (including when no handler is provided,
|
||||
* when filtering skips the interrupt, or when handler execution fails).
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { useInterrupt } from "@copilotkitnext/react";
|
||||
*
|
||||
* function InterruptUI() {
|
||||
* useInterrupt({
|
||||
* render: ({ event, resolve }) => (
|
||||
* <div>
|
||||
* <p>{event.value.question}</p>
|
||||
* <button onClick={() => resolve({ approved: true })}>Approve</button>
|
||||
* <button onClick={() => resolve({ approved: false })}>Reject</button>
|
||||
* </div>
|
||||
* ),
|
||||
* });
|
||||
*
|
||||
* return null;
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { useInterrupt } from "@copilotkitnext/react";
|
||||
*
|
||||
* function CustomPanel() {
|
||||
* const interruptElement = useInterrupt({
|
||||
* renderInChat: false,
|
||||
* enabled: (event) => event.value.startsWith("approval:"),
|
||||
* handler: async ({ event }) => ({ label: event.value.toUpperCase() }),
|
||||
* render: ({ event, result, resolve }) => (
|
||||
* <aside>
|
||||
* <strong>{result?.label ?? ""}</strong>
|
||||
* <button onClick={() => resolve({ value: event.value })}>Continue</button>
|
||||
* </aside>
|
||||
* ),
|
||||
* });
|
||||
*
|
||||
* return <>{interruptElement}</>;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
export function useInterrupt<
|
||||
TResult = never,
|
||||
TRenderInChat extends InterruptRenderInChat = undefined,
|
||||
>(
|
||||
config: UseInterruptConfig<any, TResult, TRenderInChat>,
|
||||
): UseInterruptReturn<TRenderInChat> {
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
const { copilotkit } = useCopilotKit();
|
||||
const { agent } = useAgent({ agentId: config.agentId });
|
||||
const [pendingEvent, setPendingEvent] = useState<InterruptEvent | null>(null);
|
||||
const [handlerResult, setHandlerResult] =
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
useState<InterruptResult<any, TResult>>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let localInterrupt: InterruptEvent | null = null;
|
||||
|
||||
const subscription = agent.subscribe({
|
||||
onCustomEvent: ({ event }) => {
|
||||
if (event.name === INTERRUPT_EVENT_NAME) {
|
||||
localInterrupt = { name: event.name, value: event.value };
|
||||
}
|
||||
},
|
||||
onRunStartedEvent: () => {
|
||||
localInterrupt = null;
|
||||
setPendingEvent(null);
|
||||
},
|
||||
onRunFinalized: () => {
|
||||
if (localInterrupt) {
|
||||
setPendingEvent(localInterrupt);
|
||||
localInterrupt = null;
|
||||
}
|
||||
},
|
||||
onRunFailed: () => {
|
||||
localInterrupt = null;
|
||||
},
|
||||
});
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
}, [agent]);
|
||||
|
||||
const resolve = useCallback(
|
||||
(response: unknown) => {
|
||||
setPendingEvent(null);
|
||||
copilotkit.runAgent({
|
||||
agent,
|
||||
forwardedProps: { command: { resume: response } },
|
||||
});
|
||||
},
|
||||
[agent, copilotkit],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// No interrupt to process — reset any stale handler result from a previous interrupt
|
||||
if (!pendingEvent) {
|
||||
setHandlerResult(null);
|
||||
return;
|
||||
}
|
||||
// Interrupt exists but the consumer's filter rejects it — treat as no-op
|
||||
if (config.enabled && !config.enabled(pendingEvent)) {
|
||||
setHandlerResult(null);
|
||||
return;
|
||||
}
|
||||
const handler = config.handler;
|
||||
// No handler provided — skip straight to rendering with a null result
|
||||
if (!handler) {
|
||||
setHandlerResult(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const maybePromise = handler({
|
||||
event: pendingEvent,
|
||||
resolve,
|
||||
});
|
||||
|
||||
// If the handler returns a promise/thenable, wait for resolution before setting result.
|
||||
if (isPromiseLike(maybePromise)) {
|
||||
Promise.resolve(maybePromise)
|
||||
.then((resolved) => {
|
||||
if (!cancelled) setHandlerResult(resolved);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setHandlerResult(null);
|
||||
});
|
||||
} else {
|
||||
setHandlerResult(maybePromise);
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pendingEvent, config.enabled, config.handler, resolve]);
|
||||
|
||||
const element = useMemo(() => {
|
||||
if (!pendingEvent) return null;
|
||||
if (config.enabled && !config.enabled(pendingEvent)) return null;
|
||||
|
||||
return config.render({
|
||||
event: pendingEvent,
|
||||
result: handlerResult,
|
||||
resolve,
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pendingEvent, handlerResult, config.enabled, config.render, resolve]);
|
||||
|
||||
// Publish to core for in-chat rendering
|
||||
useEffect(() => {
|
||||
if (config.renderInChat === false) return;
|
||||
copilotkit.setInterruptElement(element);
|
||||
return () => copilotkit.setInterruptElement(null);
|
||||
}, [element, config.renderInChat, copilotkit]);
|
||||
|
||||
// Only return element when rendering outside chat
|
||||
if (config.renderInChat === false) {
|
||||
return element as UseInterruptReturn<TRenderInChat>;
|
||||
}
|
||||
|
||||
return undefined as UseInterruptReturn<TRenderInChat>;
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import type {
|
||||
ReactActivityMessageRenderer,
|
||||
ReactToolCallRenderer,
|
||||
} from "@/types";
|
||||
import type { ReactCustomMessageRenderer } from "@/types/react-custom-message-renderer";
|
||||
import React from "react";
|
||||
import { ReactActivityMessageRenderer, ReactToolCallRenderer } from "@/types";
|
||||
import { ReactCustomMessageRenderer } from "@/types/react-custom-message-renderer";
|
||||
import {
|
||||
CopilotKitCore,
|
||||
type CopilotKitCoreConfig,
|
||||
@@ -24,12 +22,17 @@ export interface CopilotKitCoreReactSubscriber extends CopilotKitCoreSubscriber
|
||||
copilotkit: CopilotKitCore;
|
||||
renderToolCalls: ReactToolCallRenderer<any>[];
|
||||
}) => void | Promise<void>;
|
||||
onInterruptElementChanged?: (event: {
|
||||
copilotkit: CopilotKitCore;
|
||||
interruptElement: React.ReactElement | null;
|
||||
}) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export class CopilotKitCoreReact extends CopilotKitCore {
|
||||
private _renderToolCalls: ReactToolCallRenderer<any>[] = [];
|
||||
private _renderCustomMessages: ReactCustomMessageRenderer[] = [];
|
||||
private _renderActivityMessages: ReactActivityMessageRenderer<any>[] = [];
|
||||
private _interruptElement: React.ReactElement | null = null;
|
||||
|
||||
constructor(config: CopilotKitCoreReactConfig) {
|
||||
super(config);
|
||||
@@ -65,6 +68,21 @@ export class CopilotKitCoreReact extends CopilotKitCore {
|
||||
}, "Subscriber onRenderToolCallsChanged error:");
|
||||
}
|
||||
|
||||
get interruptElement(): React.ReactElement | null {
|
||||
return this._interruptElement;
|
||||
}
|
||||
|
||||
setInterruptElement(element: React.ReactElement | null): void {
|
||||
this._interruptElement = element;
|
||||
void this.notifySubscribers((subscriber) => {
|
||||
const reactSubscriber = subscriber as CopilotKitCoreReactSubscriber;
|
||||
reactSubscriber.onInterruptElementChanged?.({
|
||||
copilotkit: this,
|
||||
interruptElement: this._interruptElement,
|
||||
});
|
||||
}, "Subscriber onInterruptElementChanged error:");
|
||||
}
|
||||
|
||||
// Override to accept React-specific subscriber type
|
||||
subscribe(
|
||||
subscriber: CopilotKitCoreReactSubscriber,
|
||||
|
||||
@@ -4,3 +4,4 @@ export * from "./react-custom-message-renderer";
|
||||
export * from "./frontend-tool";
|
||||
export * from "./human-in-the-loop";
|
||||
export * from "./defineToolCallRenderer";
|
||||
export * from "./interrupt";
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export interface InterruptEvent<TValue = unknown> {
|
||||
name: string;
|
||||
value: TValue;
|
||||
}
|
||||
|
||||
export interface InterruptHandlerProps<TValue = unknown> {
|
||||
event: InterruptEvent<TValue>;
|
||||
resolve: (response: unknown) => void;
|
||||
}
|
||||
|
||||
export interface InterruptRenderProps<TValue = unknown, TResult = unknown> {
|
||||
event: InterruptEvent<TValue>;
|
||||
result: TResult;
|
||||
resolve: (response: unknown) => void;
|
||||
}
|
||||
Generated
+2894
-368
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ packages:
|
||||
- "packages/v2/*"
|
||||
- "examples/v1/*"
|
||||
- "examples/v2/*"
|
||||
- "examples/v2/*/apps/*"
|
||||
- "examples/v2/react/*"
|
||||
- "examples/v2/angular/*"
|
||||
- "!examples/v1/_legacy"
|
||||
|
||||
@@ -23,8 +23,8 @@ Creates and owns a `CopilotKitCore` instance that manages agents, frontend tools
|
||||
handler and a tool call renderer.
|
||||
|
||||
The provider merges the above into a `CopilotKitCore` instance, keeps render definitions in sync with React state, and
|
||||
exposes them through context. Frontend tools added through hooks (`useFrontendTool`, `useHumanInTheLoop`) are
|
||||
automatically registered and cleaned up.
|
||||
exposes them through context. Frontend tools added through hooks (`useFrontendTool`, `useHumanInTheLoop`,
|
||||
`useInterrupt`) are automatically registered and cleaned up.
|
||||
|
||||
Render your entire Copilot-enabled tree inside this provider:
|
||||
|
||||
@@ -100,6 +100,23 @@ Wraps `useFrontendTool` for interactive tools that pause agent execution. Expect
|
||||
The render component receives consistent shape based on the tool call status so you can drive bespoke UI/UX for human
|
||||
confirmations.
|
||||
|
||||
### `useInterrupt(config)`
|
||||
|
||||
Subscribes to agent custom events named `on_interrupt` and surfaces interrupt UI once a run finalizes. The hook accepts
|
||||
`UseInterruptConfig` and exposes two generics -- `TResult` (inferred from `handler` return type) and `TRenderInChat`
|
||||
(inferred from `renderInChat`). `event.value` is typed as `any` since the interrupt payload shape depends on the agent;
|
||||
type-narrow it in your callbacks as needed.
|
||||
|
||||
- `render(props)` (required) renders interrupt UI with `{ event, result, resolve }`.
|
||||
- `handler(props)?` optionally preprocesses the interrupt and can return sync or async data passed to `result`. The
|
||||
return type is automatically inferred and exposed as `result` in `render`.
|
||||
- `enabled(event)?` optionally filters interrupts handled by this instance.
|
||||
- `renderInChat?: boolean` controls rendering mode:
|
||||
- `true` (default): publishes interrupt UI inside `CopilotChat`
|
||||
- `false`: returns the interrupt element for manual placement
|
||||
|
||||
Call `resolve(response)` from the rendered UI to resume the agent with `command.resume = response`.
|
||||
|
||||
### `useRenderToolCall()`
|
||||
|
||||
Returns a renderer function that takes `{ toolCall, toolMessage, isLoading }` and returns a React element or `null`. The
|
||||
@@ -252,5 +269,5 @@ at the app boundary (after your Tailwind base) to ensure animations, prose styli
|
||||
inputs.
|
||||
3. Render `CopilotChat` for an out-of-the-box experience, or compose `CopilotChatView`, `CopilotChatMessageView`, and
|
||||
`CopilotChatInput` manually for deeper customization.
|
||||
4. Register custom tools with `useFrontendTool` or `useHumanInTheLoop`, and render tool call output with
|
||||
`useRenderToolCall` or `CopilotChatToolCallsView`.
|
||||
4. Register custom tools with `useFrontendTool` or `useHumanInTheLoop`, handle interrupts with `useInterrupt`, and
|
||||
render tool call output with `useRenderToolCall` or `CopilotChatToolCallsView`.
|
||||
|
||||
Reference in New Issue
Block a user