diff --git a/agent-streaming/README.md b/agent-streaming/README.md index cdc02ed..cbfe22f 100644 --- a/agent-streaming/README.md +++ b/agent-streaming/README.md @@ -4,13 +4,13 @@ This sample demonstrates a chatbot application that uses [Genkit](https://genkit ![Agent Streaming Chat Screenshot](screenshot.png) -The Genkit code for the streaming flow can be found in `src/server/index.js`. +The Genkit code for the streaming flow can be found in `src/server/index.ts`. ## How it works - **Backend (Express):** Exposes an endpoint `/api/chat` using Server-Sent Events (SSE). It invokes the Genkit flow `streamingThoughtsFlow`, which streams both intermediate thoughts and the final text chunks. - **Genkit Flow:** Uses `googleAI.model('gemini-3.5-flash')` with `thinkingConfig` (`includeThoughts: true`) to stream reasoning details. The flow yields custom chunk objects with `type: 'thought'` or `type: 'text'`. -- **Frontend (Vanilla JS):** Reads the Server-Sent Events stream, updates a collapsible "Thinking" card with step labels, and renders the model's Markdown text in real-time. +- **Frontend (React + TypeScript):** Reads the Server-Sent Events stream, updates a collapsible "Thinking" card with step labels, and renders the model's Markdown text in real-time. ## Running the app diff --git a/agent-streaming/index.html b/agent-streaming/index.html index 22ee8d2..80fcb9e 100644 --- a/agent-streaming/index.html +++ b/agent-streaming/index.html @@ -12,28 +12,8 @@ -
-
- -
-
- Agent will think out loud and show reasoning -
-
-
- -
- - -
-
- - +
+ \ No newline at end of file diff --git a/agent-streaming/package-lock.json b/agent-streaming/package-lock.json index 8017295..79b7b9e 100644 --- a/agent-streaming/package-lock.json +++ b/agent-streaming/package-lock.json @@ -14,9 +14,19 @@ "express": "^5.2.1", "genkit": "^1.36.0", "helmet": "^8.2.0", - "marked": "^18.0.5" + "marked": "^18.0.5", + "react": "^19.2.7", + "react-dom": "^19.2.7" }, "devDependencies": { + "@types/dompurify": "^3.0.5", + "@types/express": "^5.0.6", + "@types/node": "^25.9.3", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.2", + "tsx": "^4.22.4", + "typescript": "^6.0.3", "vite": "^8.0.16" } }, @@ -83,6 +93,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@fastify/busboy": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", @@ -3075,6 +3527,17 @@ "license": "MIT", "optional": true }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, "node_modules/@types/bunyan": { "version": "1.8.9", "resolved": "https://registry.npmjs.org/@types/bunyan/-/bunyan-1.8.9.tgz", @@ -3096,12 +3559,54 @@ "version": "3.4.36", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.36.tgz", "integrity": "sha512-P63Zd/JUGq+PdrM1lv0Wv5SBYeA2+CORvbrXbngriYY0jzLUWfQMQQxOhjONEz/wlHOAxOdY7CY65rgQdTjq2w==", + "devOptional": true, "license": "MIT", - "optional": true, "dependencies": { "@types/node": "*" } }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -3156,9 +3661,9 @@ } }, "node_modules/@types/node": { - "version": "25.9.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.2.tgz", - "integrity": "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==", + "version": "25.9.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.3.tgz", + "integrity": "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==", "license": "MIT", "dependencies": { "undici-types": ">=7.24.0 <7.24.7" @@ -3186,6 +3691,40 @@ "@types/pg": "*" } }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, "node_modules/@types/request": { "version": "2.48.13", "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.13.tgz", @@ -3199,6 +3738,27 @@ "form-data": "^2.5.5" } }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, "node_modules/@types/shimmer": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@types/shimmer/-/shimmer-1.2.0.tgz", @@ -3233,8 +3793,34 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", + "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", + "dev": true, "license": "MIT", - "optional": true + "dependencies": { + "@rolldown/pluginutils": "^1.0.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } }, "node_modules/abort-controller": { "version": "3.0.0", @@ -3717,6 +4303,13 @@ "node": ">= 8" } }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -3944,6 +4537,48 @@ "node": ">= 0.4" } }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -6180,6 +6815,27 @@ "node": ">= 0.10" } }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -6376,6 +7032,12 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, "node_modules/semver": { "version": "7.8.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.3.tgz", @@ -6867,6 +7529,25 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", + "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/type-is": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", @@ -6923,6 +7604,20 @@ "url": "https://opencollective.com/express" } }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/uglify-js": { "version": "3.19.3", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", diff --git a/agent-streaming/package.json b/agent-streaming/package.json index 6aed084..e048e2b 100644 --- a/agent-streaming/package.json +++ b/agent-streaming/package.json @@ -6,23 +6,33 @@ "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "dev:client": "vite", - "dev:server": "node --env-file=.env --watch src/server/index.js", - "build": "vite build", - "start": "node --env-file=.env src/server/index.js" + "dev:server": "tsx --env-file=.env --watch src/server/index.ts", + "build": "tsc --noEmit && vite build", + "start": "tsx --env-file=.env src/server/index.ts" }, "keywords": [], "author": "", "license": "Apache-2.0", - "type": "commonjs", + "type": "module", "dependencies": { "@genkit-ai/google-genai": "^1.36.0", "dompurify": "^3.4.9", "express": "^5.2.1", "genkit": "^1.36.0", "helmet": "^8.2.0", - "marked": "^18.0.5" + "marked": "^18.0.5", + "react": "^19.2.7", + "react-dom": "^19.2.7" }, "devDependencies": { + "@types/dompurify": "^3.0.5", + "@types/express": "^5.0.6", + "@types/node": "^25.9.3", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.2", + "tsx": "^4.22.4", + "typescript": "^6.0.3", "vite": "^8.0.16" } } diff --git a/agent-streaming/src/client/App.tsx b/agent-streaming/src/client/App.tsx new file mode 100644 index 0000000..e88a0a8 --- /dev/null +++ b/agent-streaming/src/client/App.tsx @@ -0,0 +1,166 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { ChatInput } from './components/ChatInput.js'; +import { ThoughtBox } from './components/ThoughtBox.js'; +import { MessageBubble } from './components/MessageBubble.js'; +import { Message, StreamChunk } from './types.js'; + +export const App: React.FC = () => { + const [messages, setMessages] = useState([]); + const [loading, setLoading] = useState(false); + const messagesEndRef = useRef(null); + const abortControllerRef = useRef(null); + + // Auto-scroll to bottom on messages update + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'auto' }); + }, [messages]); + + // Clean up pending stream on unmount + useEffect(() => { + return () => { + abortControllerRef.current?.abort(); + }; + }, []); + + const handlePromptSubmit = async (prompt: string) => { + setLoading(true); + + // Abort any previous request + abortControllerRef.current?.abort(); + const controller = new AbortController(); + abortControllerRef.current = controller; + + const userMessage: Message = { + id: crypto.randomUUID(), // Generate unique message IDs + role: 'user', + type: 'text', + content: prompt, + }; + + setMessages((prev) => [...prev, userMessage]); + + try { + const response = await fetch('/api/chat', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ prompt }), + signal: controller.signal, + }); + + if (!response.ok) { + throw new Error(`Network response was not ok (Status: ${response.status})`); + } + if (!response.body) { + throw new Error(`Response body is empty (Status: ${response.status})`); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + + // Store the last partial line in the buffer to prevent parsing + // errors if a chunk is split mid-line + buffer = lines.pop() || ''; + + const dataPrefix = 'data: '; + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.startsWith(dataPrefix)) { + const jsonStr = trimmed.slice(dataPrefix.length); + const chunk = JSON.parse(jsonStr) as StreamChunk; + handleStreamChunk(chunk); + } + } + } + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + return; + } + console.error('Streaming error:', error); + const errorMessage: Message = { + id: crypto.randomUUID(), + role: 'system', + type: 'error', + content: 'Failed to connect to the agent. Please try again.', + }; + setMessages((prev) => [...prev, errorMessage]); + } finally { + if (abortControllerRef.current === controller) { + setLoading(false); + } + } + }; + + const handleStreamChunk = (chunk: StreamChunk) => { + setMessages((prev) => { + const last = prev.at(-1); + + if (last && last.id === chunk.messageId) { + // Update the active message in-place + const updated = { + ...last, + content: last.content + (chunk.content || ''), + ...(last.type === 'thought' ? { stepName: chunk.currentStep || last.stepName } : {}), + } as Message; + return [...prev.slice(0, -1), updated]; + } else { + // Create a new message block and append it + const newMessage = { + id: chunk.messageId, + type: chunk.type, + role: chunk.type === 'thought' ? 'model' : chunk.type === 'error' ? 'system' : 'model', + content: chunk.content || '', + ...(chunk.type === 'thought' ? { stepName: chunk.currentStep || 'Thinking' } : {}), + } as Message; + return [...prev, newMessage]; + } + }); + }; + + return ( + <> +
+ {/* Welcome Message */} +
+
+ Agent will think out loud and show reasoning +
+
+ + {/* Conversation list */} + {messages.map((msg) => { + if (msg.type === 'thought') { + return ( + + ); + } else { + return ( + + ); + } + })} +
+
+ + + + ); +}; + +export default App; diff --git a/agent-streaming/src/client/components/ChatInput.tsx b/agent-streaming/src/client/components/ChatInput.tsx new file mode 100644 index 0000000..a050e72 --- /dev/null +++ b/agent-streaming/src/client/components/ChatInput.tsx @@ -0,0 +1,51 @@ +import React, { useState, useRef, useEffect } from 'react'; + +interface ChatInputProps { + onSubmit: (prompt: string) => void; + disabled: boolean; +} + +export const ChatInput: React.FC = ({ onSubmit, disabled }) => { + const [input, setInput] = useState(''); + const inputRef = useRef(null); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const trimmed = input.trim(); + if (trimmed && !disabled) { + onSubmit(trimmed); + setInput(''); + } + }; + + // Keep focus on input when transition completes or loading ends + useEffect(() => { + if (!disabled) { + inputRef.current?.focus(); + } + }, [disabled]); + + return ( +
+ setInput(e.target.value)} + disabled={disabled} + /> + +
+ ); +}; diff --git a/agent-streaming/src/client/components/MessageBubble.tsx b/agent-streaming/src/client/components/MessageBubble.tsx new file mode 100644 index 0000000..31ce8f7 --- /dev/null +++ b/agent-streaming/src/client/components/MessageBubble.tsx @@ -0,0 +1,49 @@ +import React, { useMemo } from 'react'; +import { marked } from 'marked'; +import DOMPurify from 'dompurify'; + +import { TextMessage, ErrorMessage } from '../types.js'; + +type MessageBubbleProps = TextMessage | ErrorMessage; + +export const MessageBubble: React.FC = ({ role, type, content }) => { + const sanitizedHtml = useMemo(() => { + if (role === 'model' && type === 'text') { + try { + const rawHtml = marked.parse(content, { breaks: true }) as string; + return DOMPurify.sanitize(rawHtml); + } catch (err) { + console.error('Markdown parsing/sanitization error:', err); + } + } + + // Content shouldn't be, or can't be, converted to HTML + return null; + }, [content, role, type]); + + // Determine container classes + let containerClass = 'message'; + if (role === 'user') { + containerClass += ' user-message'; + } else if (role === 'model') { + containerClass += ' model-message'; + } else if (role === 'system') { + containerClass += ' system-message'; + if (type === 'error') { + containerClass += ' error'; + } + } + + return ( +
+ {role === 'model' && type === 'text' && sanitizedHtml ? ( +
+ ) : ( +
{content}
+ )} +
+ ); +}; diff --git a/agent-streaming/src/client/components/ThoughtBox.tsx b/agent-streaming/src/client/components/ThoughtBox.tsx new file mode 100644 index 0000000..72d63f1 --- /dev/null +++ b/agent-streaming/src/client/components/ThoughtBox.tsx @@ -0,0 +1,36 @@ +import React, { useState } from 'react'; + +interface ThoughtBoxProps { + content: string; + stepName?: string; +} + +export const ThoughtBox: React.FC = ({ content, stepName }) => { + const [isOpen, setIsOpen] = useState(false); + const activeStep = stepName || 'Thinking'; + + return ( +
+
+
+ {/* Use key={activeStep} on indicator and label to trigger a re-animation on step change */} +
+
+ Thinking: {activeStep} +
+
+
setIsOpen(e.currentTarget.open)} + > + + + {isOpen ? 'Hide Full Reasoning' : 'Show Full Reasoning'} + + +
{content}
+
+
+
+ ); +}; diff --git a/agent-streaming/src/client/main.tsx b/agent-streaming/src/client/main.tsx new file mode 100644 index 0000000..b438e19 --- /dev/null +++ b/agent-streaming/src/client/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import './style.css'; +import App from './App.js'; + +createRoot(document.getElementById('root')!).render( + + + +); diff --git a/agent-streaming/src/client/script.js b/agent-streaming/src/client/script.js deleted file mode 100644 index afa55cd..0000000 --- a/agent-streaming/src/client/script.js +++ /dev/null @@ -1,246 +0,0 @@ -import { marked } from 'marked'; -import DOMPurify from 'dompurify'; - -document.addEventListener('DOMContentLoaded', () => { - const chatForm = document.getElementById('chat-form'); - const promptInput = document.getElementById('prompt-input'); - const chatMessages = document.getElementById('chat-messages'); - const sendButton = document.getElementById('send-button'); - - chatForm.addEventListener('submit', async (e) => { - e.preventDefault(); - const prompt = promptInput.value.trim(); - if (!prompt) return; - - // Clear input - promptInput.value = ''; - promptInput.disabled = true; - sendButton.disabled = true; - - // Append User Message Safely - appendUserMessage(prompt); - - // Setup streaming state for unified thinking card - let activeThoughtElement = null; - let activeThoughtStepLabel = null; - let activeThoughtIndicator = null; - let activeThoughtBody = null; - let activeStepName = null; - - let activeTextMessageElement = null; - let activeTextMessageContent = null; - let accumulatedModelText = ''; - - try { - const response = await fetch('/api/chat', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ prompt }), - }); - - if (!response.ok) { - throw new Error('Network response was not ok'); - } - if (!response.body) { - throw new Error('Response body is null'); - } - const reader = response.body.getReader(); - - const decoder = new TextDecoder(); - let buffer = ''; - - while (true) { - const { value, done } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n'); - - // Keep the last partial line in the buffer - buffer = lines.pop(); - - for (const line of lines) { - const trimmed = line.trim(); - if (trimmed.startsWith('data: ')) { - const jsonStr = trimmed.slice(6); - try { - const chunk = JSON.parse(jsonStr); - handleStreamChunk(chunk); - } catch (err) { - console.error('Failed to parse SSE JSON:', err); - } - } - } - } - } catch (error) { - console.error('Streaming error:', error); - appendErrorMessage('Failed to connect to the agent. Please try again.'); - } finally { - promptInput.disabled = false; - sendButton.disabled = false; - promptInput.focus(); - } - - // Handle each chunk type safely using standard elements & textContent - function handleStreamChunk(chunk) { - if (chunk.type === 'thought') { - const stepName = chunk.currentStep || 'Thinking'; - const content = chunk.content || ''; - - // If we don't have an active thought card yet, create one - if (!activeThoughtElement) { - activeStepName = stepName; - createThoughtContainer(stepName); - } else if (stepName !== activeStepName) { - // Update existing thought card for NEW THINKING animation - activeStepName = stepName; - if (activeThoughtStepLabel && activeThoughtIndicator) { - activeThoughtStepLabel.textContent = `Thinking: ${stepName}`; - - // Re-trigger new thinking animation - activeThoughtStepLabel.classList.remove('step-animate'); - activeThoughtIndicator.classList.remove('indicator-animate'); - void activeThoughtStepLabel.offsetWidth; // trigger reflow - activeThoughtStepLabel.classList.add('step-animate'); - activeThoughtIndicator.classList.add('indicator-animate'); - } - } - - // Append content to the thought body safely - if (activeThoughtBody) { - activeThoughtBody.textContent += content; - } - } else if (chunk.type === 'text') { - const content = chunk.content || ''; - accumulatedModelText += content; - - // If we don't have an active text bubble yet, create one - if (!activeTextMessageElement) { - if (activeThoughtBody) { - activeThoughtBody.textContent = activeThoughtBody.textContent.trim(); - } - createTextBubble(); - } - - // STRICT SECURE PIPELINE: 1. Parse MD -> 2. Sanitize HTML -> 3. Render via innerHTML - if (activeTextMessageContent && typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') { - try { - const parseMd = typeof marked.parse === 'function' ? marked.parse : marked; - const rawHtml = parseMd(accumulatedModelText, { breaks: true }); - const safeHtml = DOMPurify.sanitize(rawHtml); - activeTextMessageContent.innerHTML = safeHtml; - } catch (err) { - console.error('Markdown runtime parsing error:', err); - activeTextMessageContent.textContent = accumulatedModelText; - } - } else if (activeTextMessageContent) { - activeTextMessageContent.textContent = accumulatedModelText; - } - } else if (chunk.type === 'error') { - appendErrorMessage(chunk.content); - } - - // Auto scroll to bottom - chatMessages.scrollTop = chatMessages.scrollHeight; - } - - // Helper functions to safely manipulate DOM to prevent XSS - function createThoughtContainer(stepName) { - const messageDiv = document.createElement('div'); - messageDiv.className = 'message thought-message'; - - const contentDiv = document.createElement('div'); - contentDiv.className = 'thought-box'; - - const headerDiv = document.createElement('div'); - headerDiv.className = 'thought-header'; - - const indicatorDiv = document.createElement('div'); - indicatorDiv.className = 'thought-indicator indicator-animate'; - - const stepLabelDiv = document.createElement('div'); - stepLabelDiv.className = 'thought-step-label step-animate'; - stepLabelDiv.textContent = `Thinking: ${stepName}`; - - headerDiv.appendChild(indicatorDiv); - headerDiv.appendChild(stepLabelDiv); - - const details = document.createElement('details'); - details.className = 'thought-details'; - - const summary = document.createElement('summary'); - summary.className = 'thought-summary'; - - const summaryText = document.createElement('span'); - summaryText.className = 'summary-text'; - summaryText.textContent = 'Show Full Reasoning'; - - summary.appendChild(summaryText); - - const body = document.createElement('div'); - body.className = 'thought-body'; - body.textContent = ''; // starts empty - - details.appendChild(summary); - details.appendChild(body); - - details.addEventListener('toggle', () => { - summaryText.textContent = details.open ? 'Hide Full Reasoning' : 'Show Full Reasoning'; - }); - - contentDiv.appendChild(headerDiv); - contentDiv.appendChild(details); - messageDiv.appendChild(contentDiv); - chatMessages.appendChild(messageDiv); - - activeThoughtElement = messageDiv; - activeThoughtStepLabel = stepLabelDiv; - activeThoughtIndicator = indicatorDiv; - activeThoughtBody = body; - } - - function createTextBubble() { - const messageDiv = document.createElement('div'); - messageDiv.className = 'message model-message'; - - const contentDiv = document.createElement('div'); - contentDiv.className = 'message-content'; - contentDiv.textContent = ''; // starts empty - - messageDiv.appendChild(contentDiv); - chatMessages.appendChild(messageDiv); - - activeTextMessageElement = messageDiv; - activeTextMessageContent = contentDiv; - } - - }); - - function appendUserMessage(text) { - const messageDiv = document.createElement('div'); - messageDiv.className = 'message user-message'; - - const contentDiv = document.createElement('div'); - contentDiv.className = 'message-content'; - contentDiv.textContent = text; // Safe text assignment - - messageDiv.appendChild(contentDiv); - chatMessages.appendChild(messageDiv); - chatMessages.scrollTop = chatMessages.scrollHeight; - } - - function appendErrorMessage(text) { - const messageDiv = document.createElement('div'); - messageDiv.className = 'message system-message error'; - - const contentDiv = document.createElement('div'); - contentDiv.className = 'message-content'; - contentDiv.textContent = text; - - messageDiv.appendChild(contentDiv); - chatMessages.appendChild(messageDiv); - chatMessages.scrollTop = chatMessages.scrollHeight; - } -}); diff --git a/agent-streaming/src/client/types.ts b/agent-streaming/src/client/types.ts new file mode 100644 index 0000000..9bb034f --- /dev/null +++ b/agent-streaming/src/client/types.ts @@ -0,0 +1,32 @@ +export interface StreamChunk { + messageId: string; + type: 'thought' | 'text' | 'error'; + content: string; + currentStep?: string; +} + +export interface BaseMessage { + id: string; +} + +export interface TextMessage extends BaseMessage { + type: 'text'; + role: 'user' | 'model' | 'system'; + content: string; +} + +export interface ThoughtMessage extends BaseMessage { + type: 'thought'; + role: 'model'; + content: string; + stepName?: string; // For thought cards +} + +export interface ErrorMessage extends BaseMessage { + type: 'error'; + role: 'system'; + content: string; +} + +export type Message = TextMessage | ThoughtMessage | ErrorMessage; + diff --git a/agent-streaming/src/client/vite-env.d.ts b/agent-streaming/src/client/vite-env.d.ts new file mode 100644 index 0000000..b2f1f33 --- /dev/null +++ b/agent-streaming/src/client/vite-env.d.ts @@ -0,0 +1,4 @@ +// Declares Vite client-side type definitions. +// This allows TypeScript to recognize side-effect imports of static assets (like CSS, images, etc.) +// which are compiled and resolved at build-time by Vite. +/// diff --git a/agent-streaming/src/server/index.js b/agent-streaming/src/server/index.ts similarity index 72% rename from agent-streaming/src/server/index.js rename to agent-streaming/src/server/index.ts index 852d5f9..d6ce316 100644 --- a/agent-streaming/src/server/index.js +++ b/agent-streaming/src/server/index.ts @@ -1,8 +1,14 @@ -const express = require('express'); -const helmet = require('helmet'); -const path = require('path'); -const { genkit, z } = require('genkit'); -const { googleAI } = require('@genkit-ai/google-genai'); +import express, { Request, Response } from 'express'; +import helmet from 'helmet'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { genkit, z } from 'genkit'; +import { googleAI } from '@genkit-ai/google-genai'; +import process from 'process'; +import crypto from 'crypto'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); // Initialize Genkit AI const ai = genkit({ @@ -11,6 +17,7 @@ const ai = genkit({ // Define the schema for the streaming chunks const StreamChunkSchema = z.object({ + messageId: z.string(), type: z.enum(['thought', 'text']), content: z.string(), currentStep: z.string().optional(), @@ -33,11 +40,14 @@ const streamingThoughtsFlow = ai.defineFlow( config: { thinkingConfig: { includeThoughts: true, - thinkingBudget: -1, // Default thinking budget + thinkingLevel: 'MEDIUM', }, }, }); + const thoughtMessageId = crypto.randomUUID(); + const textMessageId = crypto.randomUUID(); + let accumulatedThoughts = ""; // Consume the stream as it arrives @@ -52,10 +62,11 @@ const streamingThoughtsFlow = ai.defineFlow( accumulatedThoughts += thoughtText; // Extract the most recent thought title wrapped in ** ** - const matches = [...accumulatedThoughts.matchAll(/\*\*(.*?)\*\*/g)]; - const lastStep = matches.length > 0 ? matches[matches.length - 1][1] : undefined; + const matches = accumulatedThoughts.match(/\*\*(.*?)\*\*/g); + const lastStep = matches ? matches[matches.length - 1].slice(2, -2) : undefined; sendChunk({ + messageId: thoughtMessageId, type: 'thought', content: thoughtText, currentStep: lastStep, @@ -65,6 +76,7 @@ const streamingThoughtsFlow = ai.defineFlow( const text = chunk.text; if (text) { sendChunk({ + messageId: textMessageId, type: 'text', content: text, }); @@ -78,7 +90,7 @@ const streamingThoughtsFlow = ai.defineFlow( ); const app = express(); -const PORT = process.env.PORT || 3000; +const PORT = Number(process.env.PORT) || 3000; // Security: Enable Helmet for strict security headers, including CSP. app.use( @@ -99,7 +111,7 @@ app.use(express.json()); app.use(express.static(path.join(__dirname, '../../dist'))); // API Endpoint to stream the chat response (Server-Sent Events) -app.post('/api/chat', async (req, res) => { +app.post('/api/chat', async (req: Request, res: Response) => { const { prompt } = req.body; if (typeof prompt !== 'string' || prompt.trim() === '') { @@ -122,16 +134,20 @@ app.post('/api/chat', async (req, res) => { res.end(); } catch (error) { console.error('Error in stream processing:', error); - res.write(`data: ${JSON.stringify({ type: 'error', content: 'An error occurred while generating response.' })}\n\n`); - res.end(); + if (!res.headersSent) { + res.status(500).json({ error: 'An error occurred while generating response.' }); + } else { + res.write(`data: ${JSON.stringify({ messageId: crypto.randomUUID(), type: 'error', content: 'An error occurred while generating response.' })}\n\n`); + res.end(); + } } }); // Security: Bind exclusively to 127.0.0.1 for local testing -if (require.main === module) { +if (process.argv[1] === fileURLToPath(import.meta.url)) { app.listen(PORT, '127.0.0.1', () => { console.log(`Server is running at http://127.0.0.1:${PORT}`); }); } -module.exports = { ai, streamingThoughtsFlow, app }; +export { ai, streamingThoughtsFlow, app }; diff --git a/agent-streaming/tsconfig.json b/agent-streaming/tsconfig.json new file mode 100644 index 0000000..db922d2 --- /dev/null +++ b/agent-streaming/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ESNext", + "lib": [ + "DOM", + "DOM.Iterable", + "ESNext" + ], + "module": "NodeNext", + "moduleResolution": "NodeNext", + // Support progressive migration from JS + "allowJs": true, + "skipLibCheck": true, + // Enable strict typing rules + "strict": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": [ + "src" + ] +} \ No newline at end of file diff --git a/agent-streaming/vite.config.mjs b/agent-streaming/vite.config.mjs index a8e678e..8199799 100644 --- a/agent-streaming/vite.config.mjs +++ b/agent-streaming/vite.config.mjs @@ -1,6 +1,8 @@ import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; export default defineConfig({ + plugins: [react()], server: { // Port for the Vite development frontend server port: 5173,