diff --git a/README.md b/README.md index 5081b92..50cebec 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,52 @@ const message = await complete(model!, { console.log(message.content); ``` +## Consuming from webpack / Next.js + +The packages publish ESM with `.js`-suffixed relative imports (e.g. +`from './foo.js'`), which is the correct ESM-with-TS pattern. Webpack does not +auto-rewrite `.js` → `.ts` when reading TypeScript sources directly (e.g. when +linking the workspace from `apps/`), so add an `extensionAlias` to your +`next.config.mjs`: + +```js +// next.config.mjs +export default { + transpilePackages: [ + 'agentic-kit', + '@agentic-kit/agent', + '@agentic-kit/react', + '@agentic-kit/openai', + '@agentic-kit/anthropic', + '@agentic-kit/ollama', + ], + webpack: (config) => { + config.resolve.extensionAlias = { + '.js': ['.ts', '.tsx', '.js'], + '.mjs': ['.mts', '.mjs'], + }; + return config; + }, +}; +``` + +Once a published artifact is installed (`npm install agentic-kit`), the +compiled `dist/` is what resolves and no `extensionAlias` is required — this +workaround only matters when reading TypeScript source through webpack. + +Vite, Bun, and esbuild handle `.js` → `.ts` natively. Vite users who want to +consume the workspace TypeScript source via the package `"source"` condition +can opt in with: + +```js +// vite.config.ts +export default { + resolve: { + conditions: ['source', 'import', 'module', 'browser', 'default'], + }, +}; +``` + ## Contributing See individual package READMEs for docs and local dev instructions. diff --git a/apps/nextjs-chat-demo/.env.example b/apps/nextjs-chat-demo/.env.example new file mode 100644 index 0000000..40082f5 --- /dev/null +++ b/apps/nextjs-chat-demo/.env.example @@ -0,0 +1,9 @@ +# Either OPENAI_* or LLM_* (the LLM_* convention is shared with the dashboard). +# OPENAI_* takes precedence if both are set. +OPENAI_API_KEY=sk-... +# OPENAI_BASE_URL=https://api.openai.com/v1 +# OPENAI_MODEL=gpt-5.4-mini + +# LLM_API_KEY=... +# LLM_BASE_URL=https://api.deepseek.com/v1 +# LLM_MODEL=deepseek-chat diff --git a/apps/nextjs-chat-demo/.gitignore b/apps/nextjs-chat-demo/.gitignore new file mode 100644 index 0000000..6bee4d1 --- /dev/null +++ b/apps/nextjs-chat-demo/.gitignore @@ -0,0 +1,38 @@ +# dependencies +node_modules +.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +coverage + +# next.js +.next/ +out/ +build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files +.env +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/apps/nextjs-chat-demo/README.md b/apps/nextjs-chat-demo/README.md new file mode 100644 index 0000000..54a25bf --- /dev/null +++ b/apps/nextjs-chat-demo/README.md @@ -0,0 +1,59 @@ +# nextjs-chat-demo + +A Next.js 15 demo proving `agentic-kit` can replace `@ai-sdk/react` for the +dashboard chatbot. Demonstrates: + +- streaming chat via `useChat` from `@agentic-kit/react` +- a plain server tool (`get_current_time`) +- a **pausable** server tool (`send_email`) — model proposes args, the UI shows + Allow / Deny, the answer is fed back in via `respondWithDecision`, and the + agent resumes server-side. + +## Run + +```bash +# from monorepo root +pnpm install + +# point the demo at OpenAI +export OPENAI_API_KEY=sk-... + +pnpm --filter nextjs-chat-demo dev +# open http://localhost:3001 +``` + +## AI SDK → agentic-kit migration map + +| Dashboard (AI SDK) | This demo (agentic-kit) | +| -------------------------------------------------- | -------------------------------------------------------- | +| `streamText` + `convertToModelMessages` | `Agent.prompt()` / `continue()` + `handle.toResponse()` | +| `tool({ needsApproval: true })` | `AgentTool.decision` JSON Schema | +| `addToolApprovalResponse({ id, approved })` | `respondWithDecision(toolCallId, value)` (auto re-POST) | +| `result.toUIMessageStreamResponse()` | `handle.toResponse()` | +| `useChat` from `@ai-sdk/react` | `useChat` from `@agentic-kit/react` | + +## Out of scope + +This demo deliberately does not port: + +- mentions / @-suggestions +- multi-slot queue (`messageQueue`, `isFullySettled`, `sendAutomaticallyWhen`) +- task queue UI (`plan_tasks`, `complete_task`, `approve_previous_tool`) +- ask vs agent modes, settings menu +- FAB + portal placement +- history dropdown + +These are dashboard UI sugar that sits on top of the SDK, not in it. + +## Workspace dep wiring + +`@agentic-kit/react`, `@agentic-kit/agent`, and `agentic-kit` packages declare +build outputs (`main: index.js`, `module: esm/index.js`) that don't exist on +disk in development. To consume them without a build step the demo combines: + +- `tsconfig.json` `paths` map to `../../packages/*/src/index.ts` +- `next.config.mjs` `transpilePackages` so SWC compiles the TS source +- `experimental.externalDir` so Next is happy reading from outside the app dir + +See [`PLAN.md`](./PLAN.md) for the full implementation plan and +[`GAPS.md`](./GAPS.md) for everything that felt rough to wire up. diff --git a/apps/nextjs-chat-demo/next.config.mjs b/apps/nextjs-chat-demo/next.config.mjs new file mode 100644 index 0000000..28ef9da --- /dev/null +++ b/apps/nextjs-chat-demo/next.config.mjs @@ -0,0 +1,28 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, + transpilePackages: [ + 'agentic-kit', + '@agentic-kit/agent', + '@agentic-kit/react', + '@agentic-kit/openai', + '@agentic-kit/anthropic', + '@agentic-kit/ollama', + ], + experimental: { + externalDir: true, + }, + webpack: (config) => { + // The agentic-kit packages are TS source with .js extension imports + // (`from './foo.js'`). webpack doesn't auto-rewrite those to .ts; we + // teach it to fall back to the .ts source. + config.resolve.extensionAlias = { + ...(config.resolve.extensionAlias ?? {}), + '.js': ['.ts', '.tsx', '.js'], + '.mjs': ['.mts', '.mjs'], + }; + return config; + }, +}; + +export default nextConfig; diff --git a/apps/nextjs-chat-demo/package.json b/apps/nextjs-chat-demo/package.json new file mode 100644 index 0000000..529dc21 --- /dev/null +++ b/apps/nextjs-chat-demo/package.json @@ -0,0 +1,30 @@ +{ + "name": "nextjs-chat-demo", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "next dev --port 3001", + "start": "next start --port 3001", + "lint": "next lint" + }, + "dependencies": { + "@agentic-kit/agent": "workspace:*", + "@agentic-kit/openai": "workspace:*", + "@agentic-kit/react": "workspace:*", + "agentic-kit": "workspace:*", + "clsx": "^2.1.1", + "next": "15.0.4", + "react": "19.0.0", + "react-dom": "19.0.0", + "tailwind-merge": "^3.5.0" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.1.18", + "@types/node": "^22.10.2", + "@types/react": "19.0.0", + "@types/react-dom": "19.0.0", + "tailwindcss": "^4.1.18", + "typescript": "^5.7.2" + } +} diff --git a/apps/nextjs-chat-demo/postcss.config.mjs b/apps/nextjs-chat-demo/postcss.config.mjs new file mode 100644 index 0000000..a34a3d5 --- /dev/null +++ b/apps/nextjs-chat-demo/postcss.config.mjs @@ -0,0 +1,5 @@ +export default { + plugins: { + '@tailwindcss/postcss': {}, + }, +}; diff --git a/apps/nextjs-chat-demo/src/app/api/chat/route.ts b/apps/nextjs-chat-demo/src/app/api/chat/route.ts new file mode 100644 index 0000000..6363019 --- /dev/null +++ b/apps/nextjs-chat-demo/src/app/api/chat/route.ts @@ -0,0 +1,94 @@ +import { Agent } from '@agentic-kit/agent'; +import { OpenAIAdapter } from '@agentic-kit/openai'; +import type { Message } from 'agentic-kit'; + +import { tools } from '@/lib/tools'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +const SYSTEM_PROMPT = [ + 'You are a friendly assistant in a chat-app demo.', + 'You have two tools available:', + '- get_current_time(timezone?): returns the current time in the requested IANA timezone.', + '- send_email(to, subject, body): drafts an email. The user must approve before it is sent.', + 'When the user asks for the current time anywhere, call get_current_time.', + 'When the user asks you to send an email, call send_email exactly once and wait for the user decision.', + 'Keep replies short.', +].join('\n'); + +interface RequestBody { + messages: Message[]; +} + +function lastMessageHasPendingDecision(messages: Message[]): boolean { + const last = messages[messages.length - 1]; + if (!last || last.role !== 'assistant') return false; + const completedToolCallIds = new Set( + messages + .filter((m): m is Extract => m.role === 'toolResult') + .map((m) => m.toolCallId) + ); + return last.content.some( + (block) => + block.type === 'toolCall' && + !completedToolCallIds.has(block.id) && + 'decision' in block && + block.decision !== undefined + ); +} + +export async function POST(req: Request): Promise { + const apiKey = process.env.OPENAI_API_KEY ?? process.env.LLM_API_KEY; + const baseUrl = + process.env.OPENAI_BASE_URL ?? process.env.LLM_BASE_URL ?? 'https://api.openai.com/v1'; + const modelId = process.env.OPENAI_MODEL ?? process.env.LLM_MODEL ?? 'gpt-5.4-mini'; + + if (!apiKey) { + return new Response('OPENAI_API_KEY (or LLM_API_KEY) is not set on the server', { + status: 500, + }); + } + + let body: RequestBody; + try { + body = (await req.json()) as RequestBody; + } catch { + return new Response('Invalid JSON body', { status: 400 }); + } + + const messages = Array.isArray(body.messages) ? body.messages : []; + if (messages.length === 0) { + return new Response('Empty messages', { status: 400 }); + } + + const adapter = new OpenAIAdapter({ apiKey, baseUrl }); + const model = adapter.createModel(modelId); + + const agent = new Agent({ + initialState: { model, tools, systemPrompt: SYSTEM_PROMPT }, + streamFn: (m, ctx, opts) => adapter.stream(m, ctx, opts), + maxSteps: 5, + }); + + const isResume = lastMessageHasPendingDecision(messages); + + if (isResume) { + agent.replaceMessages(messages); + try { + const handle = agent.continue(); + return handle.toResponse(); + } catch (err) { + return new Response(`continue() failed: ${(err as Error).message}`, { status: 400 }); + } + } + + const last = messages[messages.length - 1]; + if (last.role !== 'user') { + return new Response('Last message must be a user message when not resuming', { status: 400 }); + } + + agent.replaceMessages(messages.slice(0, -1)); + const handle = agent.prompt(last); + return handle.toResponse(); +} diff --git a/apps/nextjs-chat-demo/src/app/globals.css b/apps/nextjs-chat-demo/src/app/globals.css new file mode 100644 index 0000000..ba8bba4 --- /dev/null +++ b/apps/nextjs-chat-demo/src/app/globals.css @@ -0,0 +1,9 @@ +@import "tailwindcss"; + +:root { + color-scheme: light dark; +} + +html, body { + height: 100%; +} diff --git a/apps/nextjs-chat-demo/src/app/layout.tsx b/apps/nextjs-chat-demo/src/app/layout.tsx new file mode 100644 index 0000000..1db9e6c --- /dev/null +++ b/apps/nextjs-chat-demo/src/app/layout.tsx @@ -0,0 +1,18 @@ +import './globals.css'; + +import type { ReactNode } from 'react'; + +export const metadata = { + title: 'agentic-kit chat demo', + description: 'Next.js demo proving agentic-kit can replace AI SDK for the dashboard chatbot.', +}; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ); +} diff --git a/apps/nextjs-chat-demo/src/app/page.tsx b/apps/nextjs-chat-demo/src/app/page.tsx new file mode 100644 index 0000000..8d2be67 --- /dev/null +++ b/apps/nextjs-chat-demo/src/app/page.tsx @@ -0,0 +1,9 @@ +import { ChatPanel } from '@/components/chat-panel'; + +export default function Page() { + return ( +
+ +
+ ); +} diff --git a/apps/nextjs-chat-demo/src/components/chat-input.tsx b/apps/nextjs-chat-demo/src/components/chat-input.tsx new file mode 100644 index 0000000..89ebfb3 --- /dev/null +++ b/apps/nextjs-chat-demo/src/components/chat-input.tsx @@ -0,0 +1,59 @@ +'use client'; + +import { type KeyboardEvent, useState } from 'react'; + +import { cn } from '@/lib/cn'; + +interface ChatInputProps { + disabled?: boolean; + placeholder?: string; + onSend: (text: string) => void; +} + +export function ChatInput({ disabled, placeholder, onSend }: ChatInputProps) { + const [value, setValue] = useState(''); + + function submit() { + const text = value.trim(); + if (!text || disabled) return; + onSend(text); + setValue(''); + } + + function onKeyDown(e: KeyboardEvent) { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + submit(); + } + } + + return ( +
+