Skip to content

Commit cd00eaf

Browse files
authored
Merge pull request #5 from constructive-io/feat/chat-runtime
feat: chat runtime - pause/resume, SSE transport, React bindings
1 parent e1096c5 commit cd00eaf

78 files changed

Lines changed: 6463 additions & 539 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,52 @@ const message = await complete(model!, {
4545
console.log(message.content);
4646
```
4747

48+
## Consuming from webpack / Next.js
49+
50+
The packages publish ESM with `.js`-suffixed relative imports (e.g.
51+
`from './foo.js'`), which is the correct ESM-with-TS pattern. Webpack does not
52+
auto-rewrite `.js``.ts` when reading TypeScript sources directly (e.g. when
53+
linking the workspace from `apps/`), so add an `extensionAlias` to your
54+
`next.config.mjs`:
55+
56+
```js
57+
// next.config.mjs
58+
export default {
59+
transpilePackages: [
60+
'agentic-kit',
61+
'@agentic-kit/agent',
62+
'@agentic-kit/react',
63+
'@agentic-kit/openai',
64+
'@agentic-kit/anthropic',
65+
'@agentic-kit/ollama',
66+
],
67+
webpack: (config) => {
68+
config.resolve.extensionAlias = {
69+
'.js': ['.ts', '.tsx', '.js'],
70+
'.mjs': ['.mts', '.mjs'],
71+
};
72+
return config;
73+
},
74+
};
75+
```
76+
77+
Once a published artifact is installed (`npm install agentic-kit`), the
78+
compiled `dist/` is what resolves and no `extensionAlias` is required — this
79+
workaround only matters when reading TypeScript source through webpack.
80+
81+
Vite, Bun, and esbuild handle `.js``.ts` natively. Vite users who want to
82+
consume the workspace TypeScript source via the package `"source"` condition
83+
can opt in with:
84+
85+
```js
86+
// vite.config.ts
87+
export default {
88+
resolve: {
89+
conditions: ['source', 'import', 'module', 'browser', 'default'],
90+
},
91+
};
92+
```
93+
4894
## Contributing
4995

5096
See individual package READMEs for docs and local dev instructions.

apps/nextjs-chat-demo/.env.example

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Either OPENAI_* or LLM_* (the LLM_* convention is shared with the dashboard).
2+
# OPENAI_* takes precedence if both are set.
3+
OPENAI_API_KEY=sk-...
4+
# OPENAI_BASE_URL=https://api.openai.com/v1
5+
# OPENAI_MODEL=gpt-5.4-mini
6+
7+
# LLM_API_KEY=...
8+
# LLM_BASE_URL=https://api.deepseek.com/v1
9+
# LLM_MODEL=deepseek-chat

apps/nextjs-chat-demo/.gitignore

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# dependencies
2+
node_modules
3+
.pnp
4+
.pnp.*
5+
.yarn/*
6+
!.yarn/patches
7+
!.yarn/plugins
8+
!.yarn/releases
9+
!.yarn/versions
10+
11+
# testing
12+
coverage
13+
14+
# next.js
15+
.next/
16+
out/
17+
build
18+
19+
# misc
20+
.DS_Store
21+
*.pem
22+
23+
# debug
24+
npm-debug.log*
25+
yarn-debug.log*
26+
yarn-error.log*
27+
.pnpm-debug.log*
28+
29+
# env files
30+
.env
31+
.env*.local
32+
33+
# vercel
34+
.vercel
35+
36+
# typescript
37+
*.tsbuildinfo
38+
next-env.d.ts

apps/nextjs-chat-demo/README.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# nextjs-chat-demo
2+
3+
A Next.js 15 demo proving `agentic-kit` can replace `@ai-sdk/react` for the
4+
dashboard chatbot. Demonstrates:
5+
6+
- streaming chat via `useChat` from `@agentic-kit/react`
7+
- a plain server tool (`get_current_time`)
8+
- a **pausable** server tool (`send_email`) — model proposes args, the UI shows
9+
Allow / Deny, the answer is fed back in via `respondWithDecision`, and the
10+
agent resumes server-side.
11+
12+
## Run
13+
14+
```bash
15+
# from monorepo root
16+
pnpm install
17+
18+
# point the demo at OpenAI
19+
export OPENAI_API_KEY=sk-...
20+
21+
pnpm --filter nextjs-chat-demo dev
22+
# open http://localhost:3001
23+
```
24+
25+
## AI SDK → agentic-kit migration map
26+
27+
| Dashboard (AI SDK) | This demo (agentic-kit) |
28+
| -------------------------------------------------- | -------------------------------------------------------- |
29+
| `streamText` + `convertToModelMessages` | `Agent.prompt()` / `continue()` + `handle.toResponse()` |
30+
| `tool({ needsApproval: true })` | `AgentTool.decision` JSON Schema |
31+
| `addToolApprovalResponse({ id, approved })` | `respondWithDecision(toolCallId, value)` (auto re-POST) |
32+
| `result.toUIMessageStreamResponse()` | `handle.toResponse()` |
33+
| `useChat` from `@ai-sdk/react` | `useChat` from `@agentic-kit/react` |
34+
35+
## Out of scope
36+
37+
This demo deliberately does not port:
38+
39+
- mentions / @-suggestions
40+
- multi-slot queue (`messageQueue`, `isFullySettled`, `sendAutomaticallyWhen`)
41+
- task queue UI (`plan_tasks`, `complete_task`, `approve_previous_tool`)
42+
- ask vs agent modes, settings menu
43+
- FAB + portal placement
44+
- history dropdown
45+
46+
These are dashboard UI sugar that sits on top of the SDK, not in it.
47+
48+
## Workspace dep wiring
49+
50+
`@agentic-kit/react`, `@agentic-kit/agent`, and `agentic-kit` packages declare
51+
build outputs (`main: index.js`, `module: esm/index.js`) that don't exist on
52+
disk in development. To consume them without a build step the demo combines:
53+
54+
- `tsconfig.json` `paths` map to `../../packages/*/src/index.ts`
55+
- `next.config.mjs` `transpilePackages` so SWC compiles the TS source
56+
- `experimental.externalDir` so Next is happy reading from outside the app dir
57+
58+
See [`PLAN.md`](./PLAN.md) for the full implementation plan and
59+
[`GAPS.md`](./GAPS.md) for everything that felt rough to wire up.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/** @type {import('next').NextConfig} */
2+
const nextConfig = {
3+
reactStrictMode: true,
4+
transpilePackages: [
5+
'agentic-kit',
6+
'@agentic-kit/agent',
7+
'@agentic-kit/react',
8+
'@agentic-kit/openai',
9+
'@agentic-kit/anthropic',
10+
'@agentic-kit/ollama',
11+
],
12+
experimental: {
13+
externalDir: true,
14+
},
15+
webpack: (config) => {
16+
// The agentic-kit packages are TS source with .js extension imports
17+
// (`from './foo.js'`). webpack doesn't auto-rewrite those to .ts; we
18+
// teach it to fall back to the .ts source.
19+
config.resolve.extensionAlias = {
20+
...(config.resolve.extensionAlias ?? {}),
21+
'.js': ['.ts', '.tsx', '.js'],
22+
'.mjs': ['.mts', '.mjs'],
23+
};
24+
return config;
25+
},
26+
};
27+
28+
export default nextConfig;

apps/nextjs-chat-demo/package.json

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{
2+
"name": "nextjs-chat-demo",
3+
"version": "0.0.0",
4+
"private": true,
5+
"type": "module",
6+
"scripts": {
7+
"dev": "next dev --port 3001",
8+
"start": "next start --port 3001",
9+
"lint": "next lint"
10+
},
11+
"dependencies": {
12+
"@agentic-kit/agent": "workspace:*",
13+
"@agentic-kit/openai": "workspace:*",
14+
"@agentic-kit/react": "workspace:*",
15+
"agentic-kit": "workspace:*",
16+
"clsx": "^2.1.1",
17+
"next": "15.0.4",
18+
"react": "19.0.0",
19+
"react-dom": "19.0.0",
20+
"tailwind-merge": "^3.5.0"
21+
},
22+
"devDependencies": {
23+
"@tailwindcss/postcss": "^4.1.18",
24+
"@types/node": "^22.10.2",
25+
"@types/react": "19.0.0",
26+
"@types/react-dom": "19.0.0",
27+
"tailwindcss": "^4.1.18",
28+
"typescript": "^5.7.2"
29+
}
30+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export default {
2+
plugins: {
3+
'@tailwindcss/postcss': {},
4+
},
5+
};
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import { Agent } from '@agentic-kit/agent';
2+
import { OpenAIAdapter } from '@agentic-kit/openai';
3+
import type { Message } from 'agentic-kit';
4+
5+
import { tools } from '@/lib/tools';
6+
7+
export const runtime = 'nodejs';
8+
export const dynamic = 'force-dynamic';
9+
10+
const SYSTEM_PROMPT = [
11+
'You are a friendly assistant in a chat-app demo.',
12+
'You have two tools available:',
13+
'- get_current_time(timezone?): returns the current time in the requested IANA timezone.',
14+
'- send_email(to, subject, body): drafts an email. The user must approve before it is sent.',
15+
'When the user asks for the current time anywhere, call get_current_time.',
16+
'When the user asks you to send an email, call send_email exactly once and wait for the user decision.',
17+
'Keep replies short.',
18+
].join('\n');
19+
20+
interface RequestBody {
21+
messages: Message[];
22+
}
23+
24+
function lastMessageHasPendingDecision(messages: Message[]): boolean {
25+
const last = messages[messages.length - 1];
26+
if (!last || last.role !== 'assistant') return false;
27+
const completedToolCallIds = new Set(
28+
messages
29+
.filter((m): m is Extract<Message, { role: 'toolResult' }> => m.role === 'toolResult')
30+
.map((m) => m.toolCallId)
31+
);
32+
return last.content.some(
33+
(block) =>
34+
block.type === 'toolCall' &&
35+
!completedToolCallIds.has(block.id) &&
36+
'decision' in block &&
37+
block.decision !== undefined
38+
);
39+
}
40+
41+
export async function POST(req: Request): Promise<Response> {
42+
const apiKey = process.env.OPENAI_API_KEY ?? process.env.LLM_API_KEY;
43+
const baseUrl =
44+
process.env.OPENAI_BASE_URL ?? process.env.LLM_BASE_URL ?? 'https://api.openai.com/v1';
45+
const modelId = process.env.OPENAI_MODEL ?? process.env.LLM_MODEL ?? 'gpt-5.4-mini';
46+
47+
if (!apiKey) {
48+
return new Response('OPENAI_API_KEY (or LLM_API_KEY) is not set on the server', {
49+
status: 500,
50+
});
51+
}
52+
53+
let body: RequestBody;
54+
try {
55+
body = (await req.json()) as RequestBody;
56+
} catch {
57+
return new Response('Invalid JSON body', { status: 400 });
58+
}
59+
60+
const messages = Array.isArray(body.messages) ? body.messages : [];
61+
if (messages.length === 0) {
62+
return new Response('Empty messages', { status: 400 });
63+
}
64+
65+
const adapter = new OpenAIAdapter({ apiKey, baseUrl });
66+
const model = adapter.createModel(modelId);
67+
68+
const agent = new Agent({
69+
initialState: { model, tools, systemPrompt: SYSTEM_PROMPT },
70+
streamFn: (m, ctx, opts) => adapter.stream(m, ctx, opts),
71+
maxSteps: 5,
72+
});
73+
74+
const isResume = lastMessageHasPendingDecision(messages);
75+
76+
if (isResume) {
77+
agent.replaceMessages(messages);
78+
try {
79+
const handle = agent.continue();
80+
return handle.toResponse();
81+
} catch (err) {
82+
return new Response(`continue() failed: ${(err as Error).message}`, { status: 400 });
83+
}
84+
}
85+
86+
const last = messages[messages.length - 1];
87+
if (last.role !== 'user') {
88+
return new Response('Last message must be a user message when not resuming', { status: 400 });
89+
}
90+
91+
agent.replaceMessages(messages.slice(0, -1));
92+
const handle = agent.prompt(last);
93+
return handle.toResponse();
94+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
@import "tailwindcss";
2+
3+
:root {
4+
color-scheme: light dark;
5+
}
6+
7+
html, body {
8+
height: 100%;
9+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import './globals.css';
2+
3+
import type { ReactNode } from 'react';
4+
5+
export const metadata = {
6+
title: 'agentic-kit chat demo',
7+
description: 'Next.js demo proving agentic-kit can replace AI SDK for the dashboard chatbot.',
8+
};
9+
10+
export default function RootLayout({ children }: { children: ReactNode }) {
11+
return (
12+
<html lang="en">
13+
<body className="bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100">
14+
{children}
15+
</body>
16+
</html>
17+
);
18+
}

0 commit comments

Comments
 (0)