Skip to content

Commit 2e19b85

Browse files
committed
feat(sdk): add basic chat ui example
Signed-off-by: Petr Bulánek <bulanek.petr@gmail.com>
1 parent 6d4a3eb commit 2e19b85

22 files changed

Lines changed: 861 additions & 19 deletions
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
VITE_AGENTSTACK_BASE_URL="http://localhost:8333"
2+
3+
VITE_AGENTSTACK_PROVIDER_ID="983ab5fd-d7c9-0be1-77d9-442a68c5949e"
4+
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Logs
2+
logs
3+
*.log
4+
npm-debug.log*
5+
yarn-debug.log*
6+
yarn-error.log*
7+
pnpm-debug.log*
8+
lerna-debug.log*
9+
10+
node_modules
11+
dist
12+
dist-ssr
13+
*.local
14+
15+
# Editor directories and files
16+
.vscode/*
17+
!.vscode/extensions.json
18+
.idea
19+
.DS_Store
20+
*.suo
21+
*.ntvs*
22+
*.njsproj
23+
*.sln
24+
*.sw?
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* Copyright 2025 © BeeAI a Series of LF Projects, LLC
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import baseConfig from '@i-am-bee/lint-config/eslint';
7+
import { defineConfig } from 'eslint/config';
8+
import reactHooks from 'eslint-plugin-react-hooks';
9+
10+
export default defineConfig([...baseConfig, reactHooks.configs.flat['recommended-latest']]);
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<!--
2+
Copyright 2025 © BeeAI a Series of LF Projects, LLC
3+
SPDX-License-Identifier: Apache-2.0
4+
-->
5+
6+
<!doctype html>
7+
<html lang="en">
8+
<head>
9+
<meta charset="UTF-8" />
10+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
11+
12+
<title>Agent Stack Chat Example</title>
13+
</head>
14+
15+
<body>
16+
<div id="root"></div>
17+
18+
<script type="module" src="/src/main.tsx"></script>
19+
</body>
20+
</html>
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
{
2+
"name": "@i-am-bee/chat-ui-example",
3+
"author": "IBM Corp.",
4+
"private": true,
5+
"type": "module",
6+
"scripts": {
7+
"dev": "vite",
8+
"build": "tsc -b && vite build",
9+
"preview": "vite preview"
10+
},
11+
"dependencies": {
12+
"@a2a-js/sdk": "^0.3.10",
13+
"agentstack-sdk": "^0.6.1",
14+
"react": "^19.2.4",
15+
"react-dom": "^19.2.4"
16+
},
17+
"devDependencies": {
18+
"@i-am-bee/lint-config": "workspace:*",
19+
"@types/node": "^22.19.10",
20+
"@types/react": "^19.2.13",
21+
"@types/react-dom": "^19.2.3",
22+
"@vitejs/plugin-react": "^5.1.4",
23+
"babel-plugin-react-compiler": "^1.0.0",
24+
"eslint": "^9.39.2",
25+
"eslint-plugin-react-hooks": "^7.0.1",
26+
"prettier": "^3.8.1",
27+
"stylelint": "^16.26.1",
28+
"typescript": "5.9.3",
29+
"vite": "^7.3.1"
30+
},
31+
"packageManager": "pnpm@9.9.0"
32+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/**
2+
* Copyright 2025 © BeeAI a Series of LF Projects, LLC
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
export { default } from '@i-am-bee/lint-config/prettier';
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
/**
2+
* Copyright 2025 © BeeAI a Series of LF Projects, LLC
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { type SubmitEvent, useState } from 'react';
7+
8+
import { useAgent } from './client';
9+
import { BASE_URL, PROVIDER_ID } from './constants';
10+
import type { ChatMessage } from './types';
11+
import { createMessage } from './utils';
12+
13+
export function App() {
14+
const [messages, setMessages] = useState<ChatMessage[]>([]);
15+
const [input, setInput] = useState('');
16+
const [isSending, setIsSending] = useState(false);
17+
18+
const { session, isInitializing, error, sendMessage } = useAgent();
19+
20+
const isError = Boolean(error);
21+
const isSubmitDisabled = isInitializing || isSending || isError || !input.trim();
22+
23+
const handleSubmit = async (event: SubmitEvent<HTMLFormElement>) => {
24+
event.preventDefault();
25+
26+
if (isSubmitDisabled) {
27+
return;
28+
}
29+
30+
const text = input.trim();
31+
32+
setMessages((prevMessages) => [...prevMessages, createMessage({ role: 'user', text })]);
33+
setInput('');
34+
setIsSending(true);
35+
36+
try {
37+
const response = await sendMessage({ text });
38+
39+
setMessages((prevMessages) => [
40+
...prevMessages,
41+
createMessage({ role: 'agent', text: response.text || 'No response from agent.' }),
42+
]);
43+
} catch (error) {
44+
const message = error instanceof Error ? error.message : 'Failed to send message';
45+
46+
setMessages((prevMessages) => [...prevMessages, createMessage({ role: 'agent', text: `Error: ${message}` })]);
47+
} finally {
48+
setIsSending(false);
49+
}
50+
};
51+
52+
return (
53+
<main className="app">
54+
<h1 className="heading">Agent Stack Chat Example</h1>
55+
56+
<div className="meta">
57+
<p>
58+
<strong>Base URL:</strong> {BASE_URL}
59+
</p>
60+
61+
<p>
62+
<strong>Provider ID:</strong> {PROVIDER_ID}
63+
</p>
64+
65+
<p>
66+
<strong>Context ID:</strong> {session?.contextId}
67+
</p>
68+
</div>
69+
70+
<div className="controls">
71+
<button className="button" type="button" onClick={() => window.location.reload()}>
72+
New session
73+
</button>
74+
</div>
75+
76+
{messages.length > 0 && (
77+
<section className="messages">
78+
{messages.map((message) => (
79+
<article key={message.id} className={`message ${message.role}`}>
80+
<header>{message.role}</header>
81+
82+
<p>{message.text}</p>
83+
</article>
84+
))}
85+
</section>
86+
)}
87+
88+
<div className="meta">
89+
<p>
90+
<strong>Status:</strong>{' '}
91+
{isError ? 'Error' : isInitializing ? 'Connecting to agent…' : isSending ? 'Agent is thinking…' : 'Ready'}
92+
</p>
93+
94+
{isError && (
95+
<p className="error">
96+
<strong>Error:</strong> {error}
97+
</p>
98+
)}
99+
</div>
100+
101+
<form className="form" onSubmit={handleSubmit}>
102+
<input
103+
className="input"
104+
type="text"
105+
value={input}
106+
onChange={(event) => setInput(event.target.value)}
107+
placeholder="Type a message…"
108+
autoFocus
109+
/>
110+
111+
<button className="button" type="submit" disabled={isSubmitDisabled}>
112+
Send
113+
</button>
114+
</form>
115+
</main>
116+
);
117+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* Copyright 2025 © BeeAI a Series of LF Projects, LLC
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import { buildApiClient, unwrapResult } from 'agentstack-sdk';
7+
8+
import { BASE_URL, PROVIDER_ID } from './constants';
9+
10+
export const api = buildApiClient({ baseUrl: BASE_URL });
11+
12+
export async function createContext() {
13+
const result = await api.createContext({ provider_id: PROVIDER_ID });
14+
15+
return unwrapResult(result);
16+
}
17+
18+
export async function createContextToken(contextId: string) {
19+
const result = await api.createContextToken({
20+
context_id: contextId,
21+
grant_global_permissions: {
22+
a2a_proxy: [PROVIDER_ID],
23+
llm: ['*'],
24+
},
25+
grant_context_permissions: {
26+
context_data: ['*'],
27+
},
28+
});
29+
30+
return unwrapResult(result);
31+
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/**
2+
* Copyright 2025 © BeeAI a Series of LF Projects, LLC
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import {
7+
ClientFactory,
8+
ClientFactoryOptions,
9+
DefaultAgentCardResolver,
10+
JsonRpcTransportFactory,
11+
} from '@a2a-js/sdk/client';
12+
import { type ContextToken, createAuthenticatedFetch, getAgentCardPath } from 'agentstack-sdk';
13+
import { useEffect, useState } from 'react';
14+
15+
import { createContext, createContextToken } from './api';
16+
import { BASE_URL, PROVIDER_ID } from './constants';
17+
import type { Session } from './types';
18+
import { extractTextFromMessage, resolveAgentMetadata } from './utils';
19+
20+
async function ensureSession() {
21+
if (!BASE_URL || !PROVIDER_ID) {
22+
throw new Error(`Missing required environment variables.`);
23+
}
24+
25+
const context = await createContext();
26+
const contextToken = await createContextToken(context.id);
27+
const client = await createClient(contextToken);
28+
const metadata = await resolveAgentMetadata({ client, contextToken });
29+
30+
return {
31+
client,
32+
contextId: context.id,
33+
metadata,
34+
};
35+
}
36+
37+
async function createClient(contextToken: ContextToken) {
38+
const fetchImpl = createAuthenticatedFetch(contextToken.token);
39+
40+
const factory = new ClientFactory(
41+
ClientFactoryOptions.createFrom(ClientFactoryOptions.default, {
42+
transports: [new JsonRpcTransportFactory({ fetchImpl })],
43+
cardResolver: new DefaultAgentCardResolver({ fetchImpl }),
44+
}),
45+
);
46+
47+
const agentCardPath = getAgentCardPath(PROVIDER_ID);
48+
const client = await factory.createFromUrl(BASE_URL, agentCardPath);
49+
50+
return client;
51+
}
52+
53+
export function useAgent() {
54+
const [session, setSession] = useState<Session>();
55+
const [isInitializing, setIsInitializing] = useState(false);
56+
const [error, setError] = useState('');
57+
58+
useEffect(() => {
59+
if (session) {
60+
return;
61+
}
62+
63+
let cancelled = false;
64+
65+
(async () => {
66+
try {
67+
setIsInitializing(true);
68+
69+
const session = await ensureSession();
70+
71+
if (cancelled) {
72+
return;
73+
}
74+
75+
setSession(session);
76+
} catch (error) {
77+
if (cancelled) {
78+
return;
79+
}
80+
81+
const message = error instanceof Error ? error.message : 'Failed to connect to agent.';
82+
83+
setError(message);
84+
} finally {
85+
if (!cancelled) {
86+
setIsInitializing(false);
87+
}
88+
}
89+
})();
90+
91+
return () => {
92+
cancelled = true;
93+
};
94+
}, [session]);
95+
96+
const sendMessage = async ({ text }: { text: string }) => {
97+
if (!session) {
98+
throw new Error('Agent is not ready yet.');
99+
}
100+
101+
const { client, contextId, metadata } = session;
102+
103+
const runStream = async () => {
104+
const stream = client.sendMessageStream({
105+
message: {
106+
kind: 'message',
107+
role: 'user',
108+
messageId: crypto.randomUUID(),
109+
contextId,
110+
parts: [{ kind: 'text', text }],
111+
metadata,
112+
},
113+
});
114+
115+
let agentText = '';
116+
117+
for await (const event of stream) {
118+
if (event.kind === 'status-update' || event.kind === 'message') {
119+
const message = event.kind === 'message' ? event : event.status.message;
120+
const text = extractTextFromMessage(message);
121+
122+
if (text) {
123+
agentText += text;
124+
}
125+
}
126+
}
127+
128+
return {
129+
text: agentText,
130+
};
131+
};
132+
133+
return await runStream();
134+
};
135+
136+
return {
137+
session,
138+
isInitializing,
139+
error,
140+
sendMessage,
141+
};
142+
}

0 commit comments

Comments
 (0)