From 317f8695bad9b2c877f5e23acf45df95fffb3069 Mon Sep 17 00:00:00 2001 From: ABCxFF <79597906+abcxff@users.noreply.github.com> Date: Sun, 20 Apr 2025 20:57:53 -0400 Subject: [PATCH] feat: ai-agent example --- examples/ai-agent/.env.example | 3 + examples/ai-agent/.gitignore | 5 ++ examples/ai-agent/actors/app.ts | 93 ++++++++++++++++++++++++++++ examples/ai-agent/package.json | 52 ++++++++++++++++ examples/ai-agent/public/github.css | 37 +++++++++++ examples/ai-agent/public/index.html | 27 ++++++++ examples/ai-agent/public/main.css | 93 ++++++++++++++++++++++++++++ examples/ai-agent/src/App.tsx | 95 +++++++++++++++++++++++++++++ examples/ai-agent/src/index.tsx | 7 +++ examples/ai-agent/tsconfig.json | 20 ++++++ examples/ai-agent/utils/weather.ts | 35 +++++++++++ yarn.lock | 30 +++++++++ 12 files changed, 497 insertions(+) create mode 100644 examples/ai-agent/.env.example create mode 100644 examples/ai-agent/.gitignore create mode 100644 examples/ai-agent/actors/app.ts create mode 100644 examples/ai-agent/package.json create mode 100644 examples/ai-agent/public/github.css create mode 100644 examples/ai-agent/public/index.html create mode 100644 examples/ai-agent/public/main.css create mode 100644 examples/ai-agent/src/App.tsx create mode 100644 examples/ai-agent/src/index.tsx create mode 100644 examples/ai-agent/tsconfig.json create mode 100644 examples/ai-agent/utils/weather.ts diff --git a/examples/ai-agent/.env.example b/examples/ai-agent/.env.example new file mode 100644 index 000000000..187098d35 --- /dev/null +++ b/examples/ai-agent/.env.example @@ -0,0 +1,3 @@ +# OpenAI Configuration +OPENAI_API_KEY=sk-xxxxxxxxxxxx +API_NINJA_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx # Optional [will give dummy weather if not (100 F)] \ No newline at end of file diff --git a/examples/ai-agent/.gitignore b/examples/ai-agent/.gitignore new file mode 100644 index 000000000..58067ab77 --- /dev/null +++ b/examples/ai-agent/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +build/ +.turbo/ +.env +.env.local \ No newline at end of file diff --git a/examples/ai-agent/actors/app.ts b/examples/ai-agent/actors/app.ts new file mode 100644 index 000000000..311d3c0a0 --- /dev/null +++ b/examples/ai-agent/actors/app.ts @@ -0,0 +1,93 @@ +import { actor, setup } from "actor-core"; +import { generateText, jsonSchema, tool } from "ai"; +import { openai } from "@ai-sdk/openai"; +import { getWeather } from "../utils/weather"; +import dotenv from "dotenv"; + +dotenv.config(); + +export type Message = { role: "user" | "assistant"; content: string; timestamp: number; } + +const aiAgent = actor({ + // State is automatically persisted + state: { + messages: [] as Message[] + }, + + actions: { + // Get conversation history + getMessages: (c) => c.state.messages, + + // Send a message to the AI and get a response + sendMessage: async (c, userMessage: string) => { + // Add user message to conversation + const userMsg: Message = { + role: "user", + content: userMessage, + timestamp: Date.now() + }; + c.state.messages.push(userMsg); + + // Generate AI response using Vercel AI SDK with tools + const out = await generateText({ + model: openai("o3-mini"), + messages: c.state.messages, + tools: { + weather: tool({ + description: 'Get the weather in a location', + parameters: jsonSchema<{ coords: { longitude: number, latitude: number } }>({ + type: 'object', + properties: { + coords: { + type: 'object', + description: 'The location to get the weather for', + properties: { + longitude: { + type: 'number', + description: 'Longitude of the location' + }, + latitude: { + type: 'number', + description: 'Latitude of the location' + } + }, + required: ['longitude', 'latitude'], + additionalProperties: false + } + }, + required: ['coords'], + additionalProperties: false + }), + execute: async ({ coords }) => { + return await getWeather(coords); + } + }), + }, + maxSteps: 2, + }); + + const { text } = out; + + // Add AI response to conversation + const assistantMsg: Message = { + role: "assistant", + content: text, + timestamp: Date.now() + }; + c.state.messages.push(assistantMsg); + + // Broadcast the new message to all connected clients + c.broadcast("messageReceived", assistantMsg); + + return assistantMsg; + }, + } +}); + +// Create and export the app +export const app = setup({ + actors: { aiAgent }, +}); + +// Export type for client type checking +export type App = typeof app; \ No newline at end of file diff --git a/examples/ai-agent/package.json b/examples/ai-agent/package.json new file mode 100644 index 000000000..b15f72d83 --- /dev/null +++ b/examples/ai-agent/package.json @@ -0,0 +1,52 @@ +{ + "name": "ai-agent", + "version": "0.8.0", + "private": true, + "type": "module", + "main": "src/index.ts", + "scripts": { + "dev:actors": "npx @actor-core/cli@latest dev actors/app.ts", + "dev:frontend": "react-scripts start", + "build": "react-scripts build", + "check-types": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@actor-core/react": "workspace:*", + "@ai-sdk/openai": "^1.3.16", + "@types/react": "^19", + "@types/react-dom": "^19", + "actor-core": "workspace:*", + "dotenv": "^16.5.0", + "react": "^19", + "react-dom": "^19", + "react-scripts": "^5.0.1" + }, + "devDependencies": { + "@actor-core/cli": "workspace:*", + "actor-core": "workspace:*", + "typescript": "^5.5.2" + }, + "example": { + "platforms": [ + "*" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "resolutions": { + "react@^19": "^19.0.0", + "react-dom@^19": "^19.0.0", + "react@^18": "^18.3" + } +} diff --git a/examples/ai-agent/public/github.css b/examples/ai-agent/public/github.css new file mode 100644 index 000000000..49c7aca80 --- /dev/null +++ b/examples/ai-agent/public/github.css @@ -0,0 +1,37 @@ +body, html { + margin: 0; + padding: 0; + padding-top: 16px; + width: 100%; + height: 100%; +} +#example--repo-ref { + position: fixed; + top: 0px; + left: 0px; + cursor: pointer; + background-color: rgb(243, 243, 243); + height: 24px; + width: 100%; + padding: 8px 8px; +} +#example--github-icon { + height: 24px; + float: left; +} +#example--repo-link { + height: 24px; + margin-left: 8px; + color: rgb(45, 50, 55); + font-weight: bold; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; + font-size: 15px; + vertical-align: middle; +} + +#example--repo-ref:hover #example--repo-link { + color: black; +} +#example--repo-ref:hover svg { + fill: black !important; +} \ No newline at end of file diff --git a/examples/ai-agent/public/index.html b/examples/ai-agent/public/index.html new file mode 100644 index 000000000..dd611ed64 --- /dev/null +++ b/examples/ai-agent/public/index.html @@ -0,0 +1,27 @@ + + + + + + + AI Agent + + + + +
+ + + + + + @rivet-gg/actor-core +
+ +
+ + + + \ No newline at end of file diff --git a/examples/ai-agent/public/main.css b/examples/ai-agent/public/main.css new file mode 100644 index 000000000..114997fdf --- /dev/null +++ b/examples/ai-agent/public/main.css @@ -0,0 +1,93 @@ +.app { + max-width: 800px; + margin: 0 auto; + padding: 20px; + font-family:system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif +} + +h1 { + text-align: center; + color: #333; +} + +.ai-chat { + display: flex; + flex-direction: column; + height: 80vh; + border: 1px solid #ddd; + border-radius: 8px; + overflow: hidden; +} + +.messages { + flex: 1; + overflow-y: auto; + padding: 20px; +} + +.message { + display: flex; + margin-bottom: 20px; + align-items: flex-start; +} + +.message .avatar { + font-size: 24px; + margin-right: 12px; +} + +.message .content { + background: #f5f5f5; + padding: 12px; + border-radius: 8px; + max-width: 70%; +} + +.message.user .content { + background: #007bff; + color: white; +} + +.message.assistant .content { + background: #f8f9fa; +} + +.message.loading .content { + font-style: italic; + color: #666; +} + +.empty-message { + text-align: center; + color: #666; + margin-top: 40px; +} + +.input-area { + display: flex; + padding: 20px; + border-top: 1px solid #ddd; + background: white; +} + +.input-area input { + flex: 1; + padding: 10px; + border: 1px solid #ddd; + border-radius: 4px; + margin-right: 10px; +} + +.input-area button { + padding: 10px 20px; + background: #007bff; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; +} + +.input-area button:disabled { + background: #ccc; + cursor: not-allowed; +} \ No newline at end of file diff --git a/examples/ai-agent/src/App.tsx b/examples/ai-agent/src/App.tsx new file mode 100644 index 000000000..09d224b24 --- /dev/null +++ b/examples/ai-agent/src/App.tsx @@ -0,0 +1,95 @@ +import { createClient } from "actor-core/client"; +import { createReactActorCore } from "@actor-core/react"; +import { useState, useEffect } from "react"; +import type { App } from "../actors/app"; +import type { Message } from "../actors/app"; + +const client = createClient("http://localhost:6420"); +const { useActor, useActorEvent } = createReactActorCore(client); + +function AIAssistant() { + const [{ actor }] = useActor("aiAgent", { tags: { conversationId: "default" } }); + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + // Load initial messages + useEffect(() => { + if (actor) { + actor.getMessages().then(setMessages); + } + }, [actor]); + + // Listen for real-time messages + useActorEvent({ actor, event: "messageReceived" }, (message) => { + setMessages(prev => [...prev, message as Message]); + setIsLoading(false); + }); + + const handleSendMessage = async () => { + if (actor && input.trim()) { + setIsLoading(true); + + // Add user message to UI immediately + const userMessage = { role: "user", content: input, timestamp: Date.now() } as Message; + setMessages(prev => [...prev, userMessage]); + + // Send to actor (AI response will come through the event) + await actor.sendMessage(input); + setInput(""); + } + }; + + return ( +
+
+ {messages.length === 0 ? ( +
+ Ask the AI assistant a question to get started +
+ ) : ( + messages.map((msg, i) => ( +
+
+ {msg.role === "user" ? "👤" : "🤖"} +
+
{msg.content}
+
+ )) + )} + {isLoading && ( +
+
🤖
+
Thinking...
+
+ )} +
+ +
+ setInput(e.target.value)} + onKeyPress={e => e.key === "Enter" && handleSendMessage()} + placeholder="Ask the AI assistant..." + disabled={isLoading} + /> + +
+
+ ); +} + +// Export the AIAssistant as the default React component +export default function ReactApp() { + return ( +
+

AI Assistant Demo

+ +
+ ); +} \ No newline at end of file diff --git a/examples/ai-agent/src/index.tsx b/examples/ai-agent/src/index.tsx new file mode 100644 index 000000000..f3e9c5fd1 --- /dev/null +++ b/examples/ai-agent/src/index.tsx @@ -0,0 +1,7 @@ +import ReactDOM from "react-dom/client"; +import ReactApp from "./App"; + +const container = document.getElementById('root')!; +const root = ReactDOM.createRoot(container); + +root.render(); \ No newline at end of file diff --git a/examples/ai-agent/tsconfig.json b/examples/ai-agent/tsconfig.json new file mode 100644 index 000000000..e2f828a40 --- /dev/null +++ b/examples/ai-agent/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "esnext", + "lib": ["ESNext", "DOM"], + "jsx": "react-jsx", + "module": "esnext", + "moduleResolution": "bundler", + "types": ["node"], + "resolveJsonModule": true, + "allowJs": true, + "checkJs": false, + "noEmit": true, + "isolatedModules": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + }, + "include": ["src/**/*", "actors/**/*", "tests/**/*"] +} \ No newline at end of file diff --git a/examples/ai-agent/utils/weather.ts b/examples/ai-agent/utils/weather.ts new file mode 100644 index 000000000..0801750fc --- /dev/null +++ b/examples/ai-agent/utils/weather.ts @@ -0,0 +1,35 @@ + +const celsiusToFahrenheit = (celsius: number) => (celsius * 9/5) + 32; + +export async function getWeather(coords: {longitude: number, latitude: number}): Promise<{ forecast: string, temperature: number }> { + try { + // Using OpenWeatherMap API which has a free tier + const apiKey = process.env.API_NINJA_API_KEY || null; + + if (apiKey === null) { + return { forecast: "Cloudy with chance of meatballs", temperature: 100 } + } + + const url = `https://api.api-ninjas.com/v1/weatherforecast?lon=${coords.longitude}&lat=${coords.latitude}&`; + + const response = await fetch(url, { + headers: { + 'X-Api-Key': apiKey + } + }); + + if (!response.ok) { + throw new Error(`Weather API error: ${response.statusText}`); + } + + const data = await response.json(); + const today = data[0]; + const forecast = today.weather; + const temperature = celsiusToFahrenheit(today.temp); + + return {temperature: Math.floor(temperature), forecast: forecast}; + } catch (error) { + console.error('Error fetching weather data:', error); + throw new Error('Failed to fetch weather data'); + } +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 3c5634791..e094aabb4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -256,6 +256,18 @@ __metadata: languageName: node linkType: hard +"@ai-sdk/openai@npm:^1.3.16": + version: 1.3.16 + resolution: "@ai-sdk/openai@npm:1.3.16" + dependencies: + "@ai-sdk/provider": "npm:1.1.3" + "@ai-sdk/provider-utils": "npm:2.2.7" + peerDependencies: + zod: ^3.0.0 + checksum: 10c0/e1c21d2e104611f91d05e5431ba6399a804c220185a8e14423723d6fa2dfafc546865702b2dd6e6a70de24c6ff485ba1f90f384ed12dde7e384fb17583498df0 + languageName: node + linkType: hard + "@ai-sdk/provider-utils@npm:2.2.7": version: 2.2.7 resolution: "@ai-sdk/provider-utils@npm:2.2.7" @@ -6217,6 +6229,24 @@ __metadata: languageName: node linkType: hard +"ai-agent@workspace:examples/ai-agent": + version: 0.0.0-use.local + resolution: "ai-agent@workspace:examples/ai-agent" + dependencies: + "@actor-core/cli": "workspace:*" + "@actor-core/react": "workspace:*" + "@ai-sdk/openai": "npm:^1.3.16" + "@types/react": "npm:^19" + "@types/react-dom": "npm:^19" + actor-core: "workspace:*" + dotenv: "npm:^16.5.0" + react: "npm:^19" + react-dom: "npm:^19" + react-scripts: "npm:^5.0.1" + typescript: "npm:^5.5.2" + languageName: unknown + linkType: soft + "ai@npm:^4.3.9": version: 4.3.9 resolution: "ai@npm:4.3.9"