chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"ignoreRules": ["cjs-resolves-to-esm", "no-resolution", "untyped-resolution"],
|
||||
"excludeEntrypoints": ["./styles.css"]
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
# Angular build cache
|
||||
.angular/
|
||||
|
||||
# Turbo cache
|
||||
.turbo/
|
||||
|
||||
# Source files (only publish dist)
|
||||
src/
|
||||
ng-package.json
|
||||
tsconfig.lib.json
|
||||
tsconfig.spec.json
|
||||
|
||||
# Development files
|
||||
*.spec.ts
|
||||
*.spec.js
|
||||
*.test.ts
|
||||
*.test.js
|
||||
vitest.config.ts
|
||||
jest.config.js
|
||||
|
||||
# Config files
|
||||
tailwind.config.js
|
||||
postcss.config.js
|
||||
.eslintrc.js
|
||||
.prettierrc
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
.cache/
|
||||
|
||||
# Documentation
|
||||
*.md
|
||||
!README.md
|
||||
|
||||
# Git
|
||||
.git/
|
||||
.gitignore
|
||||
@@ -0,0 +1,578 @@
|
||||
# @copilotkit/angular
|
||||
|
||||
## 1.54.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkit/core@1.55.2
|
||||
- @copilotkit/shared@1.55.2
|
||||
|
||||
## 1.54.3-next.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkit/core@1.55.2-next.1
|
||||
- @copilotkit/shared@1.55.2-next.1
|
||||
|
||||
## 1.54.3-next.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkit/core@1.55.2-next.0
|
||||
- @copilotkit/shared@1.55.2-next.0
|
||||
|
||||
## 1.54.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkit/core@1.55.1
|
||||
- @copilotkit/shared@1.55.1
|
||||
|
||||
## 1.54.2-next.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkit/core@1.55.1-next.0
|
||||
- @copilotkit/shared@1.55.1-next.0
|
||||
|
||||
## 1.54.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- b4a8b7a: Add auto-detection of runtime transport (REST vs single-endpoint)
|
||||
- Updated dependencies [1ceb963]
|
||||
- Updated dependencies [b4a8b7a]
|
||||
- Updated dependencies [1ceb963]
|
||||
- Updated dependencies [52a9322]
|
||||
- Updated dependencies [1ceb963]
|
||||
- Updated dependencies [5289791]
|
||||
- Updated dependencies [434ccd8]
|
||||
- @copilotkit/core@1.55.0
|
||||
- @copilotkit/shared@1.55.0
|
||||
|
||||
## 1.54.1-next.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkit/core@1.55.0-next.8
|
||||
- @copilotkit/shared@1.55.0-next.8
|
||||
|
||||
## 1.54.1-next.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [1ceb963]
|
||||
- @copilotkit/core@1.55.0-next.7
|
||||
- @copilotkit/shared@1.55.0-next.7
|
||||
|
||||
## 1.54.1-next.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkitnext/core@1.54.1-next.6
|
||||
- @copilotkitnext/shared@1.54.1-next.6
|
||||
|
||||
## 1.54.1-next.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkitnext/core@1.54.1-next.5
|
||||
- @copilotkitnext/shared@1.54.1-next.5
|
||||
|
||||
## 1.54.1-next.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [8113dba]
|
||||
- @copilotkitnext/core@1.54.1-next.4
|
||||
- @copilotkitnext/shared@1.54.1-next.4
|
||||
|
||||
## 1.54.1-next.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkitnext/core@1.54.1-next.3
|
||||
- @copilotkitnext/shared@1.54.1-next.3
|
||||
|
||||
## 1.54.1-next.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkitnext/core@1.54.1-next.2
|
||||
- @copilotkitnext/shared@1.54.1-next.2
|
||||
|
||||
## 1.54.1-next.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [d423001]
|
||||
- @copilotkitnext/core@1.54.1-next.1
|
||||
- @copilotkitnext/shared@1.54.1-next.1
|
||||
|
||||
## 1.54.1-next.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkitnext/core@1.54.1-next.0
|
||||
- @copilotkitnext/shared@1.54.1-next.0
|
||||
|
||||
## 1.54.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 3780c6a: deprecate: mark all @copilotkitnext/_ packages as deprecated in favor of @copilotkit/_ equivalents
|
||||
- Updated dependencies [eb1e0bf]
|
||||
- Updated dependencies [3780c6a]
|
||||
- Updated dependencies [c80498e]
|
||||
- Updated dependencies [f1571ef]
|
||||
- @copilotkitnext/core@1.54.0
|
||||
- @copilotkitnext/shared@1.54.0
|
||||
|
||||
## 1.54.0-next.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 3780c6a: deprecate: mark all @copilotkitnext/_ packages as deprecated in favor of @copilotkit/_ equivalents
|
||||
- Updated dependencies [3780c6a]
|
||||
- @copilotkitnext/shared@1.54.0-next.9
|
||||
- @copilotkitnext/core@1.54.0-next.9
|
||||
|
||||
## 1.54.0-next.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [f1571ef]
|
||||
- @copilotkitnext/core@1.54.0-next.8
|
||||
- @copilotkitnext/shared@1.54.0-next.8
|
||||
|
||||
## 1.54.0-next.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [eb1e0bf]
|
||||
- @copilotkitnext/core@1.54.0-next.7
|
||||
- @copilotkitnext/shared@1.54.0-next.7
|
||||
|
||||
## 1.54.0-next.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [c80498e]
|
||||
- @copilotkitnext/core@1.54.0-next.6
|
||||
- @copilotkitnext/shared@1.54.0-next.6
|
||||
|
||||
## 1.54.0-next.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkitnext/core@1.54.0-next.5
|
||||
- @copilotkitnext/shared@1.54.0-next.5
|
||||
|
||||
## 1.54.0-next.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkitnext/core@1.54.0-next.4
|
||||
- @copilotkitnext/shared@1.54.0-next.4
|
||||
|
||||
## 1.54.0-next.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkitnext/core@1.54.0-next.3
|
||||
- @copilotkitnext/shared@1.54.0-next.3
|
||||
|
||||
## 1.53.1-next.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkit/core@1.53.1-next.2
|
||||
- @copilotkit/shared@1.53.1-next.2
|
||||
|
||||
## 1.53.1-next.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkit/core@1.53.1-next.1
|
||||
- @copilotkit/shared@1.53.1-next.1
|
||||
|
||||
## 1.53.1-next.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkit/core@1.53.1-next.0
|
||||
- @copilotkit/shared@1.53.1-next.0
|
||||
|
||||
## 1.53.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 1510f64: feat: enable mcp and a2ui middleware directly from copilotkit runtime
|
||||
- Updated dependencies [1510f64]
|
||||
- @copilotkit/core@1.53.0
|
||||
- @copilotkit/shared@1.53.0
|
||||
|
||||
## 1.53.0-next.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkit/core@1.53.0-next.6
|
||||
- @copilotkit/shared@1.53.0-next.6
|
||||
|
||||
## 1.53.0-next.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkit/core@1.53.0-next.5
|
||||
- @copilotkit/shared@1.53.0-next.5
|
||||
|
||||
## 1.52.2-next.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkit/core@1.52.2-next.4
|
||||
- @copilotkit/shared@1.52.2-next.4
|
||||
|
||||
## 1.52.2-next.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 1510f64: feat: enable mcp and a2ui middleware directly from copilotkit runtime
|
||||
- Updated dependencies [1510f64]
|
||||
- @copilotkit/core@1.52.2-next.3
|
||||
- @copilotkit/shared@1.52.2-next.3
|
||||
|
||||
## 1.52.2-next.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkit/core@1.52.2-next.2
|
||||
- @copilotkit/shared@1.52.2-next.2
|
||||
|
||||
## 1.52.2-next.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkit/core@1.52.2-next.1
|
||||
- @copilotkit/shared@1.52.2-next.1
|
||||
|
||||
## 1.52.2-next.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkit/core@1.52.2-next.0
|
||||
- @copilotkit/shared@1.52.2-next.0
|
||||
|
||||
## 1.52.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkit/core@1.52.1
|
||||
- @copilotkit/shared@1.52.1
|
||||
|
||||
## 1.52.1-next.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkit/core@1.52.1-next.1
|
||||
- @copilotkit/shared@1.52.1-next.1
|
||||
|
||||
## 1.52.1-next.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkit/core@1.52.1-next.0
|
||||
- @copilotkit/shared@1.52.1-next.0
|
||||
|
||||
## 1.52.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ef0f539: Add reasoning support and default components for reasoning messages
|
||||
- Updated dependencies [d77f347]
|
||||
- Updated dependencies [ef0f539]
|
||||
- @copilotkit/core@1.52.0
|
||||
- @copilotkit/shared@1.52.0
|
||||
|
||||
## 1.52.0-next.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkit/core@1.52.0-next.8
|
||||
- @copilotkit/shared@1.52.0-next.8
|
||||
|
||||
## 1.52.0-next.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [d77f347]
|
||||
- @copilotkit/core@1.52.0-next.7
|
||||
- @copilotkit/shared@1.52.0-next.7
|
||||
|
||||
## 1.52.0-next.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkit/core@1.52.0-next.6
|
||||
- @copilotkit/shared@1.52.0-next.6
|
||||
|
||||
## 1.52.0-next.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkit/core@1.52.0-next.5
|
||||
- @copilotkit/shared@1.52.0-next.5
|
||||
|
||||
## 1.51.5-next.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkit/core@1.51.5-next.4
|
||||
- @copilotkit/shared@1.51.5-next.4
|
||||
|
||||
## 1.51.5-next.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkit/core@1.51.5-next.3
|
||||
- @copilotkit/shared@1.51.5-next.3
|
||||
|
||||
## 1.51.5-next.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkit/core@1.51.5-next.2
|
||||
- @copilotkit/shared@1.51.5-next.2
|
||||
|
||||
## 1.51.5-next.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkit/core@1.51.5-next.1
|
||||
- @copilotkit/shared@1.51.5-next.1
|
||||
|
||||
## 1.51.5-next.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- ef0f539: Add reasoning support and default components for reasoning messages
|
||||
- Updated dependencies [ef0f539]
|
||||
- @copilotkit/shared@1.51.5-next.0
|
||||
- @copilotkit/core@1.51.5-next.0
|
||||
|
||||
## 1.51.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 9bc868d: Frontend tools integration with core in Angular package
|
||||
- Updated dependencies [c998f30]
|
||||
- @copilotkit/core@1.51.4
|
||||
- @copilotkit/shared@1.51.4
|
||||
|
||||
## 1.51.4-next.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 9bc868d: Frontend tools integration with core in Angular package
|
||||
- @copilotkit/core@1.51.4-next.8
|
||||
- @copilotkit/shared@1.51.4-next.8
|
||||
|
||||
## 1.51.4-next.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkit/core@1.51.4-next.7
|
||||
- @copilotkit/shared@1.51.4-next.7
|
||||
|
||||
## 1.51.4-next.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkit/core@1.51.4-next.6
|
||||
- @copilotkit/shared@1.51.4-next.6
|
||||
|
||||
## 1.51.4-next.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkit/core@1.51.4-next.5
|
||||
- @copilotkit/shared@1.51.4-next.5
|
||||
|
||||
## 1.51.4-next.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [c998f30]
|
||||
- @copilotkit/core@1.51.4-next.4
|
||||
- @copilotkit/shared@1.51.4-next.4
|
||||
|
||||
## 1.51.4-next.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkit/core@1.51.4-next.3
|
||||
- @copilotkit/shared@1.51.4-next.3
|
||||
|
||||
## 1.51.4-next.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkit/core@1.51.4-next.2
|
||||
- @copilotkit/shared@1.51.4-next.2
|
||||
|
||||
## 1.51.4-next.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkit/core@1.51.4-next.1
|
||||
- @copilotkit/shared@1.51.4-next.1
|
||||
|
||||
## 1.51.4-next.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkit/core@1.51.4-next.0
|
||||
- @copilotkit/shared@1.51.4-next.0
|
||||
|
||||
## 1.51.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- d36fc1e: Add UMD export
|
||||
- Updated dependencies [d36fc1e]
|
||||
- @copilotkit/shared@1.51.3
|
||||
- @copilotkit/core@1.51.3
|
||||
|
||||
## 1.51.3-next.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- d36fc1e: Add UMD export
|
||||
- Updated dependencies [d36fc1e]
|
||||
- @copilotkit/shared@1.51.3-next.8
|
||||
- @copilotkit/core@1.51.3-next.8
|
||||
|
||||
## 1.51.3-next.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkit/core@1.51.3-next.7
|
||||
- @copilotkit/shared@1.51.3-next.7
|
||||
|
||||
## 1.51.3-next.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkit/core@1.51.3-next.6
|
||||
- @copilotkit/shared@1.51.3-next.6
|
||||
|
||||
## 1.51.3-next.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkit/core@1.51.3-next.5
|
||||
- @copilotkit/shared@1.51.3-next.5
|
||||
|
||||
## 1.51.3-next.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkit/core@1.51.3-next.4
|
||||
- @copilotkit/shared@1.51.3-next.4
|
||||
|
||||
## 1.51.3-next.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkit/core@1.51.3-next.3
|
||||
- @copilotkit/shared@1.51.3-next.3
|
||||
|
||||
## 1.51.3-next.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkit/core@1.51.3-next.2
|
||||
- @copilotkit/shared@1.51.3-next.2
|
||||
|
||||
## 1.51.3-next.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkit/core@1.51.3-next.1
|
||||
- @copilotkit/shared@1.51.3-next.1
|
||||
|
||||
## 1.51.3-next.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkit/core@1.51.3-next.0
|
||||
- @copilotkit/shared@1.51.3-next.0
|
||||
|
||||
## 1.51.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- e59d23f: Use deps instead of peerdeps
|
||||
- Updated dependencies [e59d23f]
|
||||
- @copilotkit/shared@1.51.2
|
||||
- @copilotkit/core@1.51.2
|
||||
|
||||
## 1.51.2-next.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- e59d23f: Use deps instead of peerdeps
|
||||
- Updated dependencies [e59d23f]
|
||||
- @copilotkit/shared@1.51.2-next.1
|
||||
- @copilotkit/core@1.51.2-next.1
|
||||
|
||||
## 1.51.2-next.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkit/core@1.51.2-next.0
|
||||
- @copilotkit/shared@1.51.2-next.0
|
||||
|
||||
## 1.51.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkit/core@1.51.1
|
||||
- @copilotkit/shared@1.51.1
|
||||
|
||||
## 1.51.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 2839a15: Update versioning strategy
|
||||
- Updated dependencies [2839a15]
|
||||
- @copilotkit/shared@1.51.0
|
||||
- @copilotkit/core@1.51.0
|
||||
|
||||
## 1.51.0-next.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkit/core@1.51.0-next.4
|
||||
- @copilotkit/shared@1.51.0-next.4
|
||||
|
||||
## 1.51.0-next.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkit/core@1.51.0-next.3
|
||||
- @copilotkit/shared@1.51.0-next.3
|
||||
|
||||
## 1.51.0-next.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkit/core@1.51.0-next.2
|
||||
- @copilotkit/shared@1.51.0-next.2
|
||||
|
||||
## 1.51.0-next.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @copilotkit/core@1.51.0-next.1
|
||||
- @copilotkit/shared@1.51.0-next.1
|
||||
@@ -0,0 +1,21 @@
|
||||
The MIT License
|
||||
|
||||
Copyright (c) Tawkit Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -0,0 +1,326 @@
|
||||
# CopilotKit for Angular
|
||||
|
||||
Angular bindings for CopilotKit core and AG-UI agents. This package provides services, directives, and utilities for building custom, headless Copilot UIs.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm install @copilotkit/angular
|
||||
```
|
||||
|
||||
Peer dependencies you provide in your app:
|
||||
|
||||
- `@angular/core` and `@angular/common` (19, 20, or 21)
|
||||
- `@angular/cdk` (match your Angular major)
|
||||
- `rxjs`
|
||||
|
||||
## Quick start
|
||||
|
||||
### 1) Provide CopilotKit
|
||||
|
||||
Configure runtime and tools in your app config:
|
||||
|
||||
```ts
|
||||
import { ApplicationConfig } from "@angular/core";
|
||||
import { provideCopilotKit } from "@copilotkit/angular";
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideCopilotKit({
|
||||
runtimeUrl: "http://localhost:3001/api/copilotkit",
|
||||
headers: { Authorization: "Bearer ..." },
|
||||
properties: { app: "demo" },
|
||||
}),
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
### 2) Build a custom UI with `injectAgentStore`
|
||||
|
||||
```ts
|
||||
import { Component, inject, signal } from "@angular/core";
|
||||
import { Message } from "@ag-ui/client";
|
||||
import { CopilotKit, injectAgentStore } from "@copilotkit/angular";
|
||||
import { randomUUID } from "@copilotkit/shared";
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
@for (let message of messages(); track message.id) {
|
||||
<div>
|
||||
<em>{{ message.role }}</em>
|
||||
<p>{{ message.content }}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<input
|
||||
[value]="input()"
|
||||
(input)="input.set($any($event.target).value)"
|
||||
(keyup.enter)="send()"
|
||||
/>
|
||||
<button (click)="send()" [disabled]="store().isRunning()">Send</button>
|
||||
`,
|
||||
})
|
||||
export class HeadlessChatComponent {
|
||||
readonly copilotKit = inject(CopilotKit);
|
||||
readonly store = injectAgentStore("default");
|
||||
readonly messages = this.store().messages;
|
||||
|
||||
readonly input = signal("");
|
||||
|
||||
async send() {
|
||||
const content = this.input().trim();
|
||||
if (!content) return;
|
||||
|
||||
const agent = this.store().agent;
|
||||
|
||||
agent.addMessage({
|
||||
id: randomUUID(),
|
||||
role: "user",
|
||||
content,
|
||||
});
|
||||
|
||||
this.input.set("");
|
||||
|
||||
await this.copilotKit.core.runAgent({ agent });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `agent` is an AG-UI `AbstractAgent`. Refer to your AG-UI agent implementation for available methods and message formats.
|
||||
|
||||
## Core configuration
|
||||
|
||||
### `CopilotKitConfig`
|
||||
|
||||
`provideCopilotKit` accepts a `CopilotKitConfig` object:
|
||||
|
||||
```ts
|
||||
export interface CopilotKitConfig {
|
||||
runtimeUrl?: string;
|
||||
headers?: Record<string, string>;
|
||||
licenseKey?: string;
|
||||
properties?: Record<string, unknown>;
|
||||
agents?: Record<string, AbstractAgent>;
|
||||
tools?: ClientTool[];
|
||||
renderToolCalls?: RenderToolCallConfig[];
|
||||
frontendTools?: FrontendToolConfig[];
|
||||
humanInTheLoop?: HumanInTheLoopConfig[];
|
||||
}
|
||||
```
|
||||
|
||||
- `runtimeUrl`: URL to your CopilotKit runtime.
|
||||
- `headers`: Default headers sent to the runtime.
|
||||
- `properties`: Arbitrary props forwarded to agent runs.
|
||||
- `agents`: Local, in-browser agents keyed by `agentId`.
|
||||
- `tools`: Tool definitions advertised to the runtime (no handler).
|
||||
- `renderToolCalls`: Components to render tool calls in the UI.
|
||||
- `frontendTools`: Client-side tools with handlers.
|
||||
- `humanInTheLoop`: Tools that pause for user input.
|
||||
|
||||
### Injection helpers
|
||||
|
||||
- `provideCopilotKit(config)`: Provider for `CopilotKitConfig`.
|
||||
|
||||
## `CopilotKit` service
|
||||
|
||||
### Readonly signals
|
||||
|
||||
- `agents`: `Signal<Record<string, AbstractAgent>>`
|
||||
- `runtimeConnectionStatus`: `Signal<CopilotKitCoreRuntimeConnectionStatus>`
|
||||
- `runtimeUrl`: `Signal<string | undefined>`
|
||||
- `runtimeTransport`: `Signal<CopilotRuntimeTransport>` (`"rest" | "single"`)
|
||||
- `headers`: `Signal<Record<string, string>>`
|
||||
- `toolCallRenderConfigs`: `Signal<RenderToolCallConfig[]>`
|
||||
- `clientToolCallRenderConfigs`: `Signal<FrontendToolConfig[]>`
|
||||
- `humanInTheLoopToolRenderConfigs`: `Signal<HumanInTheLoopConfig[]>`
|
||||
|
||||
### Methods
|
||||
|
||||
- `getAgent(agentId: string): AbstractAgent | undefined`
|
||||
- `addFrontendTool(config: FrontendToolConfig & { injector: Injector }): void`
|
||||
- `addRenderToolCall(config: RenderToolCallConfig): void`
|
||||
- `addHumanInTheLoop(config: HumanInTheLoopConfig): void`
|
||||
- `removeTool(toolName: string, agentId?: string): void`
|
||||
- `updateRuntime(options: { runtimeUrl?: string; runtimeTransport?: CopilotRuntimeTransport; headers?: Record<string,string>; properties?: Record<string, unknown>; agents?: Record<string, AbstractAgent>; }): void`
|
||||
|
||||
### Advanced
|
||||
|
||||
- `core`: The underlying `CopilotKitCore` instance.
|
||||
|
||||
## Agents
|
||||
|
||||
### `injectAgentStore`
|
||||
|
||||
```ts
|
||||
const store = injectAgentStore("default");
|
||||
// or: injectAgentStore(signal(agentId))
|
||||
```
|
||||
|
||||
Returns a `Signal<AgentStore>`. The store exposes:
|
||||
|
||||
- `agent`: `AbstractAgent`
|
||||
- `messages`: `Signal<Message[]>`
|
||||
- `state`: `Signal<any>`
|
||||
- `isRunning`: `Signal<boolean>`
|
||||
- `teardown()`: Clean up subscriptions
|
||||
|
||||
If the agent is not available locally but a `runtimeUrl` is configured, a proxy agent is created while the runtime connects. If the agent still cannot be resolved, an error is thrown that includes the configured runtime and known agent IDs.
|
||||
|
||||
### `CopilotkitAgentFactory`
|
||||
|
||||
Advanced factory for creating `AgentStore` signals. Most apps should use `injectAgentStore` instead.
|
||||
|
||||
## Agent context
|
||||
|
||||
### `connectAgentContext`
|
||||
|
||||
Connect AG-UI context to the runtime (auto-cleanup when the effect is destroyed):
|
||||
|
||||
```ts
|
||||
import { connectAgentContext } from "@copilotkit/angular";
|
||||
|
||||
connectAgentContext({
|
||||
description: "User preferences",
|
||||
value: { theme: "dark" },
|
||||
});
|
||||
```
|
||||
|
||||
You must call it within an injection context (e.g., inside a component constructor or `runInInjectionContext`), or pass an explicit `Injector`:
|
||||
|
||||
```ts
|
||||
connectAgentContext(contextSignal, { injector });
|
||||
```
|
||||
|
||||
## Tools and tool rendering
|
||||
|
||||
### Types
|
||||
|
||||
```ts
|
||||
export interface RenderToolCallConfig<Args> {
|
||||
name: string; // tool name, or "*" for wildcard
|
||||
args: z.ZodType<Args>; // Zod schema for args
|
||||
component: Type<ToolRenderer<Args>>;
|
||||
agentId?: string; // optional agent scope
|
||||
}
|
||||
|
||||
export interface FrontendToolConfig<Args> {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: z.ZodType<Args>;
|
||||
component?: Type<ToolRenderer<Args>>; // optional UI renderer
|
||||
handler: (args: Args, context: FrontendToolHandlerContext) => Promise<unknown>;
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
export interface HumanInTheLoopConfig<Args> {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: z.ZodType<Args>;
|
||||
component: Type<HumanInTheLoopToolRenderer<Args>>;
|
||||
agentId?: string;
|
||||
}
|
||||
|
||||
export type ClientTool<Args> = Omit<FrontendTool<Args>, \"handler\"> & {
|
||||
renderer?: Type<ToolRenderer<Args>>;
|
||||
};
|
||||
```
|
||||
|
||||
Renderer components receive a signal:
|
||||
|
||||
```ts
|
||||
export interface ToolRenderer<Args> {
|
||||
toolCall: Signal<AngularToolCall<Args>>;
|
||||
}
|
||||
|
||||
export interface HumanInTheLoopToolRenderer<Args> {
|
||||
toolCall: Signal<HumanInTheLoopToolCall<Args>>; // includes respond(result)
|
||||
}
|
||||
```
|
||||
|
||||
`AngularToolCall` / `HumanInTheLoopToolCall` expose `args`, `status` (`"in-progress" | "executing" | "complete"`), and `result`.
|
||||
|
||||
### Register tools with DI
|
||||
|
||||
These helpers auto-remove tools when the current injection context is destroyed:
|
||||
Call them from an injection context (e.g., a component constructor, directive, or `runInInjectionContext`).
|
||||
|
||||
```ts
|
||||
import {
|
||||
registerFrontendTool,
|
||||
registerRenderToolCall,
|
||||
registerHumanInTheLoop,
|
||||
} from "@copilotkit/angular";
|
||||
import { z } from "zod";
|
||||
|
||||
registerFrontendTool({
|
||||
name: "lookup",
|
||||
description: "Fetch a record",
|
||||
parameters: z.object({ id: z.string() }),
|
||||
handler: async ({ id }) => ({ id, ok: true }),
|
||||
});
|
||||
|
||||
registerRenderToolCall({
|
||||
name: "*", // wildcard renderer
|
||||
args: z.any(),
|
||||
component: MyToolCallRenderer,
|
||||
});
|
||||
|
||||
registerHumanInTheLoop({
|
||||
name: "approval",
|
||||
description: "Request approval",
|
||||
parameters: z.object({ reason: z.string() }),
|
||||
component: ApprovalRenderer,
|
||||
});
|
||||
```
|
||||
|
||||
### Configure tools in `provideCopilotKit`
|
||||
|
||||
```ts
|
||||
provideCopilotKit({
|
||||
frontendTools: [
|
||||
/* FrontendToolConfig[] */
|
||||
],
|
||||
renderToolCalls: [
|
||||
/* RenderToolCallConfig[] */
|
||||
],
|
||||
humanInTheLoop: [
|
||||
/* HumanInTheLoopConfig[] */
|
||||
],
|
||||
tools: [
|
||||
/* ClientTool[] */
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
`tools` are advertised to the runtime. If you include `renderer` + `parameters` on a `ClientTool`, CopilotKit will also register a renderer for tool calls.
|
||||
|
||||
## `RenderToolCalls` component
|
||||
|
||||
`RenderToolCalls` renders tool call components under an assistant message based on registered render configs.
|
||||
|
||||
```html
|
||||
<copilot-render-tool-calls
|
||||
[message]="assistantMessage"
|
||||
[messages]="messages"
|
||||
[isLoading]="isRunning"
|
||||
></copilot-render-tool-calls>
|
||||
```
|
||||
|
||||
Inputs:
|
||||
|
||||
- `message`: `AssistantMessage` (must include `toolCalls`)
|
||||
- `messages`: full `Message[]` list (used to find tool results)
|
||||
- `isLoading`: whether the agent is currently running
|
||||
|
||||
Tool arguments are parsed with `partialJSONParse`, so incomplete JSON during streaming still renders.
|
||||
|
||||
## Runtime notes
|
||||
|
||||
- Set `runtimeUrl` to your CopilotKit runtime endpoint.
|
||||
- If you need to change runtime settings at runtime, call `CopilotKit.updateRuntime(...)`.
|
||||
- `runtimeTransport` supports `"rest"` or `"single"` (SSE single-stream transport).
|
||||
|
||||
## Not documented here
|
||||
|
||||
This package also exports a full set of chat UI components under `src/lib/components/chat`. Those APIs are intentionally omitted from this README.
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
|
||||
"dest": "dist",
|
||||
"lib": {
|
||||
"entryFile": "src/index.ts"
|
||||
},
|
||||
"allowedNonPeerDependencies": [
|
||||
"@ag-ui/client",
|
||||
"@ag-ui/core",
|
||||
"@copilotkit/a2ui-renderer",
|
||||
"@copilotkit/shared",
|
||||
"@copilotkit/core",
|
||||
"@copilotkit/web-components",
|
||||
"@jetbrains/websandbox",
|
||||
"rxjs",
|
||||
"zod",
|
||||
"zod-to-json-schema",
|
||||
"lucide-angular",
|
||||
"highlight.js",
|
||||
"katex",
|
||||
"marked",
|
||||
"clsx",
|
||||
"tailwind-merge"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"name": "@copilotkit/angular",
|
||||
"version": "0.1.1",
|
||||
"description": "Angular library for CopilotKit",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/CopilotKit/CopilotKit.git",
|
||||
"directory": "packages/angular"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "dist/fesm2022/copilotkit-angular.mjs",
|
||||
"module": "dist/fesm2022/copilotkit-angular.mjs",
|
||||
"types": "dist/types/copilotkit-angular.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/types/copilotkit-angular.d.ts",
|
||||
"default": "./dist/fesm2022/copilotkit-angular.mjs"
|
||||
}
|
||||
},
|
||||
"./styles.css": "./dist/styles.css"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "ng-packagr -p ng-package.json && npm run build:css",
|
||||
"build:css": "tailwindcss -i ./src/styles/globals.css -o ./src/styles/generated.css && node scripts/scope-preflight.mjs ./src/styles/generated.css && node -e \"const fs=require('fs');fs.mkdirSync('./dist',{recursive:true});fs.cpSync('./src/styles/generated.css','./dist/styles.css')\"",
|
||||
"dev": "concurrently \"ng-packagr -p ng-package.json --watch\" \"npm run dev:css\"",
|
||||
"dev:css": "tailwindcss -i ./src/styles/globals.css -o ./src/styles/generated.css --watch",
|
||||
"check-types": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest --watch",
|
||||
"publint": "publint .",
|
||||
"attw": "attw --pack . --profile node16"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ag-ui/client": "0.0.57",
|
||||
"@ag-ui/core": "0.0.57",
|
||||
"@copilotkit/a2ui-renderer": "workspace:*",
|
||||
"@copilotkit/core": "workspace:*",
|
||||
"@copilotkit/shared": "workspace:*",
|
||||
"@copilotkit/web-components": "workspace:*",
|
||||
"@jetbrains/websandbox": "^1.1.3",
|
||||
"clsx": "^2.1.1",
|
||||
"highlight.js": "^11.11.1",
|
||||
"katex": "^0.16.22",
|
||||
"lucide-angular": "^0.540.0",
|
||||
"marked": "^16.2.0",
|
||||
"rxjs": "^7.8.1",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tslib": "^2.6.0",
|
||||
"zod-to-json-schema": "^3.24.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@analogjs/vite-plugin-angular": "^1.20.2",
|
||||
"@analogjs/vitest-angular": "^1.20.2",
|
||||
"@angular/cdk": "^21.2.13",
|
||||
"@angular/common": "^21.2.15",
|
||||
"@angular/compiler": "^21.2.15",
|
||||
"@angular/compiler-cli": "^21.2.15",
|
||||
"@angular/core": "^21.2.15",
|
||||
"@angular/platform-browser": "^21.2.15",
|
||||
"@angular/platform-browser-dynamic": "^21.2.15",
|
||||
"@copilotkit/typescript-config": "workspace:*",
|
||||
"@lucide/build-icons": "^1.1.0",
|
||||
"@tailwindcss/cli": "^4.1.11",
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@types/katex": "^0.16.7",
|
||||
"@types/node": "^22.5.1",
|
||||
"@vitest/ui": "^3.2.4",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"concurrently": "^9.1.0",
|
||||
"jsdom": "^24.0.0",
|
||||
"ng-packagr": "^21.2.3",
|
||||
"postcss": "^8.4.31",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rimraf": "^6.0.1",
|
||||
"rxjs": "^7.8.1",
|
||||
"tailwindcss": "^4.0.8",
|
||||
"tslib": "^2.8.1",
|
||||
"tw-animate-css": "^1.3.5",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "^7.1.4",
|
||||
"vitest": "^3.2.4",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/cdk": "^19.0.0 || ^20.0.0 || ^21.0.0",
|
||||
"@angular/common": "^19.0.0 || ^20.0.0 || ^21.0.0",
|
||||
"@angular/core": "^19.0.0 || ^20.0.0 || ^21.0.0",
|
||||
"rxjs": "^7.8.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Post-processes compiled Tailwind CSS to scope all @layer base rules
|
||||
* under [data-copilotkit], preventing CopilotKit styles from leaking
|
||||
* into the host application.
|
||||
*
|
||||
* Run after `tailwindcss` CLI: node scripts/scope-preflight.mjs <file>
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
import postcss from "postcss";
|
||||
|
||||
const SCOPE = "[data-copilotkit]";
|
||||
const file = process.argv[2];
|
||||
if (!file) {
|
||||
console.error("Usage: node scripts/scope-preflight.mjs <css-file>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
/** Selectors that are already scoped and should be left alone. */
|
||||
function isAlreadyScoped(selector) {
|
||||
return (
|
||||
selector.includes("[data-copilot") || selector.includes("[data-sidebar")
|
||||
);
|
||||
}
|
||||
|
||||
/** Rewrite a single selector to be scoped under [data-copilotkit]. */
|
||||
function scopeSelector(sel) {
|
||||
sel = sel.trim();
|
||||
|
||||
// Already scoped — keep as-is
|
||||
if (isAlreadyScoped(sel)) return sel;
|
||||
|
||||
// html, :host → [data-copilotkit]
|
||||
if (sel === "html" || sel === ":host" || sel === "html,:host") {
|
||||
return SCOPE;
|
||||
}
|
||||
|
||||
// body → null (remove)
|
||||
if (sel === "body") return null;
|
||||
|
||||
// ::backdrop → null (cannot be scoped to a container)
|
||||
if (sel === "::backdrop") return null;
|
||||
|
||||
// Bare universal selector → scope to container + descendants
|
||||
if (sel === "*") return `${SCOPE}, ${SCOPE} *`;
|
||||
|
||||
// Pseudo-elements (::) and vendor pseudo-classes (:-) → descendant only
|
||||
// These can't be combined with an attribute selector suffix
|
||||
if (sel.startsWith(":")) {
|
||||
return `${SCOPE} ${sel}`;
|
||||
}
|
||||
|
||||
// Element / attribute selectors → descendant AND self-matching
|
||||
// e.g. button → [data-copilotkit] button, button[data-copilotkit]
|
||||
// This ensures <button data-copilotkit> also receives the preflight reset
|
||||
return `${SCOPE} ${sel}, ${sel}${SCOPE}`;
|
||||
}
|
||||
|
||||
function scopeRule(rule) {
|
||||
const newSelectors = [];
|
||||
|
||||
for (const sel of rule.selectors) {
|
||||
const scoped = scopeSelector(sel);
|
||||
if (scoped !== null) {
|
||||
// scopeSelector may return comma-separated selectors (for *)
|
||||
if (typeof scoped === "string" && scoped.includes(", ")) {
|
||||
newSelectors.push(...scoped.split(", "));
|
||||
} else if (scoped) {
|
||||
newSelectors.push(scoped);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (newSelectors.length === 0) {
|
||||
rule.remove();
|
||||
} else {
|
||||
rule.selectors = newSelectors;
|
||||
}
|
||||
}
|
||||
|
||||
/** Recursively scope all rules within a node (handles @supports, @media, etc.) */
|
||||
function scopeChildren(node) {
|
||||
node.walk((child) => {
|
||||
if (child.type === "rule") {
|
||||
scopeRule(child);
|
||||
}
|
||||
// @supports / @media blocks are walked automatically
|
||||
});
|
||||
}
|
||||
|
||||
// --- Main ---
|
||||
const css = readFileSync(file, "utf8");
|
||||
const root = postcss.parse(css);
|
||||
|
||||
root.walkAtRules("layer", (layer) => {
|
||||
if (layer.params !== "base") return;
|
||||
scopeChildren(layer);
|
||||
});
|
||||
|
||||
writeFileSync(file, root.toString());
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./public-api";
|
||||
@@ -0,0 +1,309 @@
|
||||
import {
|
||||
EnvironmentInjector,
|
||||
createEnvironmentInjector,
|
||||
runInInjectionContext,
|
||||
signal,
|
||||
} from "@angular/core";
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { test, expect, vi } from "vitest";
|
||||
import { HttpAgent } from "@ag-ui/client";
|
||||
import {
|
||||
injectChatConfiguration,
|
||||
provideCopilotChatConfiguration,
|
||||
} from "./chat-configuration";
|
||||
import type { AgentStore } from "./agent";
|
||||
import { connectActiveThread } from "./active-thread-connector";
|
||||
|
||||
/**
|
||||
* Builds a fake agent + agent-store signal and wires the connector under an
|
||||
* injection context with a real {@link CopilotChatConfiguration}.
|
||||
*
|
||||
* @returns The config service, the fake agent, and the connect spy.
|
||||
*/
|
||||
function setup() {
|
||||
const fake = {
|
||||
agent: {
|
||||
threadId: "t0",
|
||||
messages: [{ id: "m1" }] as { id: string }[],
|
||||
setMessages: vi.fn((arr: { id: string }[]) => {
|
||||
fake.agent.messages = arr;
|
||||
}),
|
||||
detachActiveRun: vi.fn(() => Promise.resolve()),
|
||||
},
|
||||
};
|
||||
|
||||
const connect = vi.fn();
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideCopilotChatConfiguration()],
|
||||
});
|
||||
|
||||
const config = TestBed.runInInjectionContext(() => {
|
||||
const cfg = injectChatConfiguration();
|
||||
const agentStore = signal(fake as never) as unknown as () => AgentStore;
|
||||
connectActiveThread(cfg, agentStore, connect);
|
||||
return cfg;
|
||||
});
|
||||
|
||||
return { config, fake, connect };
|
||||
}
|
||||
|
||||
test("explicit switch connects the agent to the picked thread", async () => {
|
||||
const { config, fake, connect } = setup();
|
||||
|
||||
config.setActiveThreadId("picked-1");
|
||||
TestBed.flushEffects();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(fake.agent.threadId).toBe("picked-1");
|
||||
expect(connect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ agent: fake.agent }),
|
||||
);
|
||||
});
|
||||
|
||||
test("initial mount does not clear messages", async () => {
|
||||
const { fake, connect } = setup();
|
||||
|
||||
TestBed.flushEffects();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(fake.agent.setMessages).not.toHaveBeenCalled();
|
||||
expect(fake.agent.messages).toEqual([{ id: "m1" }]);
|
||||
expect(connect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("an explicit switch detaches the prior in-flight run on re-run", async () => {
|
||||
const { config, fake } = setup();
|
||||
|
||||
config.setActiveThreadId("picked-1");
|
||||
TestBed.flushEffects();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(fake.agent.detachActiveRun).not.toHaveBeenCalled();
|
||||
|
||||
config.setActiveThreadId("picked-2");
|
||||
TestBed.flushEffects();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(fake.agent.detachActiveRun).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("destroying the injector detaches the connected run", async () => {
|
||||
const fake = {
|
||||
agent: {
|
||||
threadId: "t0",
|
||||
messages: [{ id: "m1" }] as { id: string }[],
|
||||
setMessages: vi.fn(),
|
||||
detachActiveRun: vi.fn(() => Promise.resolve()),
|
||||
},
|
||||
};
|
||||
const connect = vi.fn();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideCopilotChatConfiguration()],
|
||||
});
|
||||
const parent = TestBed.inject(EnvironmentInjector);
|
||||
const childInjector = createEnvironmentInjector([], parent);
|
||||
const config = runInInjectionContext(childInjector, () => {
|
||||
const cfg = injectChatConfiguration();
|
||||
const agentStore = signal(fake as never) as unknown as () => AgentStore;
|
||||
connectActiveThread(cfg, agentStore, connect);
|
||||
return cfg;
|
||||
});
|
||||
|
||||
config.setActiveThreadId("picked-1");
|
||||
TestBed.flushEffects();
|
||||
await Promise.resolve();
|
||||
|
||||
childInjector.destroy();
|
||||
|
||||
expect(fake.agent.detachActiveRun).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("a genuine new-thread transition clears messages and skips connect", async () => {
|
||||
const { config, fake } = setup();
|
||||
|
||||
config.setActiveThreadId("picked");
|
||||
TestBed.flushEffects();
|
||||
await Promise.resolve();
|
||||
|
||||
config.startNewThread();
|
||||
TestBed.flushEffects();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(fake.agent.setMessages).toHaveBeenCalledWith([]);
|
||||
expect(fake.agent.messages).toEqual([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Builds a fake agent + agent-store signal and wires the connector with
|
||||
* explicit cursor hooks and a caller-supplied connect implementation.
|
||||
*
|
||||
* @param connect - The connect implementation under test.
|
||||
* @returns The config service, the fake agent, the connect spy, and the
|
||||
* `onConnectStart`/`onConnectSettle` hook spies.
|
||||
*/
|
||||
function setupWithHooks(connect: (params: { agent: unknown }) => unknown) {
|
||||
const fake = {
|
||||
agent: {
|
||||
threadId: "t0",
|
||||
messages: [{ id: "m1" }] as { id: string }[],
|
||||
setMessages: vi.fn((arr: { id: string }[]) => {
|
||||
fake.agent.messages = arr;
|
||||
}),
|
||||
detachActiveRun: vi.fn(() => Promise.resolve()),
|
||||
},
|
||||
};
|
||||
|
||||
const onConnectStart = vi.fn();
|
||||
const onConnectSettle = vi.fn();
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideCopilotChatConfiguration()],
|
||||
});
|
||||
|
||||
const config = TestBed.runInInjectionContext(() => {
|
||||
const cfg = injectChatConfiguration();
|
||||
const agentStore = signal(fake as never) as unknown as () => AgentStore;
|
||||
connectActiveThread(cfg, agentStore, connect as never, {
|
||||
onConnectStart,
|
||||
onConnectSettle,
|
||||
});
|
||||
return cfg;
|
||||
});
|
||||
|
||||
return { config, fake, onConnectStart, onConnectSettle };
|
||||
}
|
||||
|
||||
test("N1: a rejecting connect does not raise an unhandled rejection and still settles the cursor", async () => {
|
||||
const unhandled: unknown[] = [];
|
||||
const onUnhandled = (event: PromiseRejectionEvent) => {
|
||||
unhandled.push(event.reason);
|
||||
};
|
||||
globalThis.addEventListener?.("unhandledrejection", onUnhandled);
|
||||
|
||||
const { config, onConnectStart, onConnectSettle } = setupWithHooks(() =>
|
||||
Promise.reject(new Error("connect failed")),
|
||||
);
|
||||
|
||||
config.setActiveThreadId("picked-1", { explicit: true });
|
||||
TestBed.flushEffects();
|
||||
|
||||
// Flush microtasks so the connect promise and its caught/finally chain run.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(onConnectStart).toHaveBeenCalledTimes(1);
|
||||
expect(onConnectSettle).toHaveBeenCalledTimes(1);
|
||||
expect(unhandled).toEqual([]);
|
||||
|
||||
globalThis.removeEventListener?.("unhandledrejection", onUnhandled);
|
||||
});
|
||||
|
||||
test("N2: a superseded connect does not settle the cursor; the live connect does", async () => {
|
||||
let resolveFirst: (() => void) | undefined;
|
||||
let resolveSecond: (() => void) | undefined;
|
||||
const deferreds: Array<Promise<void>> = [
|
||||
new Promise<void>((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
}),
|
||||
new Promise<void>((resolve) => {
|
||||
resolveSecond = resolve;
|
||||
}),
|
||||
];
|
||||
let call = 0;
|
||||
const connect = vi.fn(() => deferreds[call++]);
|
||||
|
||||
const { config, onConnectSettle } = setupWithHooks(connect);
|
||||
|
||||
config.setActiveThreadId("picked-1", { explicit: true });
|
||||
TestBed.flushEffects();
|
||||
await Promise.resolve();
|
||||
|
||||
// Second explicit thread supersedes the first connect before it settles.
|
||||
config.setActiveThreadId("picked-2", { explicit: true });
|
||||
TestBed.flushEffects();
|
||||
await Promise.resolve();
|
||||
|
||||
// Settle the FIRST (now superseded) connect: its settle must be suppressed.
|
||||
resolveFirst?.();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(onConnectSettle).not.toHaveBeenCalled();
|
||||
|
||||
// Settle the SECOND (live) connect: its settle fires.
|
||||
resolveSecond?.();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
expect(onConnectSettle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("N3: cleanup detaches the connected run", async () => {
|
||||
const fake = {
|
||||
agent: {
|
||||
threadId: "t0",
|
||||
messages: [{ id: "m1" }] as { id: string }[],
|
||||
setMessages: vi.fn(),
|
||||
detachActiveRun: vi.fn(() => Promise.resolve()),
|
||||
},
|
||||
};
|
||||
const connect = vi.fn(() => Promise.resolve());
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideCopilotChatConfiguration()],
|
||||
});
|
||||
const parent = TestBed.inject(EnvironmentInjector);
|
||||
const childInjector = createEnvironmentInjector([], parent);
|
||||
const config = runInInjectionContext(childInjector, () => {
|
||||
const cfg = injectChatConfiguration();
|
||||
const agentStore = signal(fake as never) as unknown as () => AgentStore;
|
||||
connectActiveThread(cfg, agentStore, connect as never);
|
||||
return cfg;
|
||||
});
|
||||
|
||||
config.setActiveThreadId("picked-1", { explicit: true });
|
||||
TestBed.flushEffects();
|
||||
await Promise.resolve();
|
||||
|
||||
childInjector.destroy();
|
||||
|
||||
expect(fake.agent.detachActiveRun).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("N3: cleanup aborts the agent's AbortController for an HttpAgent", async () => {
|
||||
const abort = vi.fn();
|
||||
// A minimal HttpAgent: the connector sets `agent.abortController` only when
|
||||
// `agent instanceof HttpAgent`, so use a real instance with a stubbed
|
||||
// controller to assert the abort fires on teardown.
|
||||
const agent = new HttpAgent({ url: "http://localhost/agent" });
|
||||
agent.abortController = { abort } as never;
|
||||
const detachSpy = vi
|
||||
.spyOn(agent, "detachActiveRun")
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
const fake = { agent };
|
||||
const connect = vi.fn(() => Promise.resolve());
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideCopilotChatConfiguration()],
|
||||
});
|
||||
const parent = TestBed.inject(EnvironmentInjector);
|
||||
const childInjector = createEnvironmentInjector([], parent);
|
||||
const config = runInInjectionContext(childInjector, () => {
|
||||
const cfg = injectChatConfiguration();
|
||||
const agentStore = signal(fake as never) as unknown as () => AgentStore;
|
||||
connectActiveThread(cfg, agentStore, connect as never);
|
||||
return cfg;
|
||||
});
|
||||
|
||||
config.setActiveThreadId("picked-1", { explicit: true });
|
||||
TestBed.flushEffects();
|
||||
await Promise.resolve();
|
||||
|
||||
childInjector.destroy();
|
||||
|
||||
// The connector replaced `agent.abortController` with its own per-run
|
||||
// controller before connecting; assert it aborted on teardown and detached.
|
||||
expect(detachSpy).toHaveBeenCalledTimes(1);
|
||||
expect(agent.abortController?.signal.aborted).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { effect, untracked, type Signal } from "@angular/core";
|
||||
import { HttpAgent, type AbstractAgent } from "@ag-ui/client";
|
||||
import type { AgentStore } from "./agent";
|
||||
import type { CopilotChatConfiguration } from "./chat-configuration";
|
||||
|
||||
/**
|
||||
* Signature of `CopilotKitCore.connectAgent`, injected for testability.
|
||||
*
|
||||
* The active thread is carried on `agent.threadId`, which the connector sets
|
||||
* before invoking this function; there is no separate thread parameter. The
|
||||
* connector receives the raw `core.connectAgent`, not a cursor-wrapping fn —
|
||||
* the cursor lifecycle is owned by the connector via {@link ConnectActiveThreadCursorHooks}.
|
||||
*/
|
||||
export type ConnectAgentFn = (params: { agent: AbstractAgent }) => unknown;
|
||||
|
||||
/**
|
||||
* Loading-cursor hooks the connector drives around an explicit connect, with
|
||||
* the same timing as the standalone `<copilot-chat>` `connectToAgent` path:
|
||||
* cursor on at connect start, off when that connect settles — but only if its
|
||||
* run was not superseded by a newer connect (the staleness guard).
|
||||
*/
|
||||
export interface ConnectActiveThreadCursorHooks {
|
||||
/** Called synchronously when an explicit connect begins (cursor on). */
|
||||
onConnectStart?: () => void;
|
||||
/** Called when that connect settles, ONLY if its run was not superseded (cursor off). */
|
||||
onConnectSettle?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires the active chat thread to the live agent.
|
||||
*
|
||||
* Reactively observes the resolved thread id (and whether it was chosen
|
||||
* explicitly) from {@link CopilotChatConfiguration} and the current agent from
|
||||
* the agent-store signal. On every change it pins the thread onto
|
||||
* `agent.threadId`, then:
|
||||
*
|
||||
* - **Explicit switch** (user picked a thread): connects the agent to that
|
||||
* thread via {@link ConnectAgentFn}, owning the loading-cursor + abort +
|
||||
* detach lifecycle of the standalone `<copilot-chat>` `connectToAgent` path.
|
||||
* (Connect-error *logging* is the one intentional divergence — see below.)
|
||||
* Before connecting it installs a per-run `AbortController` on the
|
||||
* agent (only when the agent is an {@link HttpAgent}) and calls
|
||||
* {@link ConnectActiveThreadCursorHooks.onConnectStart}. When that connect
|
||||
* settles it calls {@link ConnectActiveThreadCursorHooks.onConnectSettle} —
|
||||
* but only if the run was not superseded (the `detached` staleness guard), so
|
||||
* a stale connect cannot clear the cursor out from under a newer one. The
|
||||
* connect's promise is caught before its `finally` so a rejecting connect
|
||||
* never produces an unhandled rejection. Connect errors surface via the
|
||||
* AgentStore's run/error subscription and are intentionally NOT re-logged here
|
||||
* — the deliberate divergence from `connectToAgent`, which additionally
|
||||
* `console.error`s unexpected (non-`AGUIConnectNotImplementedError`) failures.
|
||||
* On the next effect re-run
|
||||
* (thread/agent switch) and on destroy, the cleanup aborts the in-flight
|
||||
* request via the per-run controller AND detaches the run via
|
||||
* `agent.detachActiveRun()`, mirroring the standalone teardown so a rapid
|
||||
* switch or component destroy does not leak a prior run.
|
||||
* - **Fresh / non-explicit switch** (e.g. {@link CopilotChatConfiguration.startNewThread}):
|
||||
* clears the agent's messages via `agent.setMessages([])` and skips the
|
||||
* connect — the runtime assigns the server thread id on first send. The clear
|
||||
* fires only on an actual transition to a *new* fresh thread id, never on the
|
||||
* initial mount nor on an agent-store swap that leaves the thread id unchanged
|
||||
* (which would otherwise wipe a resumed/shared agent's existing history).
|
||||
*
|
||||
* Tracked reads happen in the effect's reactive scope; all mutation and the
|
||||
* connect call run inside `untracked()` so they do not register as
|
||||
* dependencies (mirrors the effect/untracked idiom in `threads.ts`).
|
||||
*
|
||||
* @param config - The chat configuration exposing the resolved thread signals.
|
||||
* @param agentStore - Signal yielding the current {@link AgentStore}.
|
||||
* @param connectAgent - The raw `CopilotKitCore.connectAgent` implementation.
|
||||
* @param hooks - Optional loading-cursor hooks driven around the explicit connect.
|
||||
*/
|
||||
export function connectActiveThread(
|
||||
config: CopilotChatConfiguration,
|
||||
agentStore: Signal<AgentStore>,
|
||||
connectAgent: ConnectAgentFn,
|
||||
hooks?: ConnectActiveThreadCursorHooks,
|
||||
): void {
|
||||
// Tracks the thread id observed on the previous effect run so the
|
||||
// non-explicit branch can distinguish a genuine new-thread transition from
|
||||
// the initial mount (`undefined`) or an agent-store swap that left the thread
|
||||
// unchanged. Clearing only on a real transition prevents wiping a
|
||||
// resumed/shared agent's existing message history.
|
||||
let lastThreadId: string | undefined;
|
||||
effect((onCleanup) => {
|
||||
const threadId = config.threadId();
|
||||
const explicit = config.hasExplicitThreadId();
|
||||
const store = agentStore();
|
||||
untracked(() => {
|
||||
const agent = store.agent;
|
||||
agent.threadId = threadId;
|
||||
if (explicit) {
|
||||
// Mirror the standalone `connectToAgent` cursor/abort/detach lifecycle:
|
||||
// a per-run staleness flag, a per-run AbortController installed on
|
||||
// HttpAgents, cursor-on, a single caught connect chain, and an
|
||||
// abort+detach cleanup. (Connect-error logging is intentionally omitted
|
||||
// here — errors surface via the AgentStore run/error subscription.)
|
||||
let detached = false;
|
||||
const abortController = new AbortController();
|
||||
if (agent instanceof HttpAgent) {
|
||||
agent.abortController = abortController;
|
||||
}
|
||||
|
||||
hooks?.onConnectStart?.();
|
||||
|
||||
const result = connectAgent({ agent });
|
||||
Promise.resolve(result)
|
||||
.catch(() => {
|
||||
// connect errors surface via the AgentStore's run/error
|
||||
// subscription, not here.
|
||||
})
|
||||
.finally(() => {
|
||||
if (!detached) hooks?.onConnectSettle?.();
|
||||
});
|
||||
|
||||
// Mirror the standalone `<copilot-chat>` teardown: mark the run stale,
|
||||
// abort the in-flight request, and detach the run for the agent
|
||||
// connected this run. Fires on the next effect re-run (thread/agent
|
||||
// switch) and on destroy so a prior run does not leak.
|
||||
onCleanup(() => {
|
||||
detached = true;
|
||||
abortController.abort();
|
||||
agent.detachActiveRun().catch(() => {});
|
||||
});
|
||||
} else if (lastThreadId !== undefined && threadId !== lastThreadId) {
|
||||
// Real switch to a new fresh thread; not mount and not a same-thread swap.
|
||||
agent.setMessages([]);
|
||||
}
|
||||
lastThreadId = threadId;
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Type, Signal } from "@angular/core";
|
||||
import type { AbstractAgent, ActivityMessage } from "@ag-ui/client";
|
||||
|
||||
export type AngularActivityContentParseResult<T> =
|
||||
| { success: true; data: T }
|
||||
| { success: false; error?: unknown };
|
||||
|
||||
export interface AngularActivityContentSchema<T> {
|
||||
safeParse(content: unknown): AngularActivityContentParseResult<T>;
|
||||
}
|
||||
|
||||
export interface ActivityRenderer<TActivityContent = unknown> {
|
||||
activityType: Signal<string>;
|
||||
content: Signal<TActivityContent>;
|
||||
message: Signal<ActivityMessage>;
|
||||
agent: Signal<AbstractAgent | undefined>;
|
||||
}
|
||||
|
||||
export interface RenderActivityMessageConfig<TActivityContent = unknown> {
|
||||
activityType: string;
|
||||
agentId?: string;
|
||||
content: AngularActivityContentSchema<TActivityContent>;
|
||||
component: Type<ActivityRenderer<TActivityContent>>;
|
||||
}
|
||||
|
||||
export const anyActivityContentSchema: AngularActivityContentSchema<unknown> = {
|
||||
safeParse: (content: unknown) => ({ success: true, data: content }),
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Component, signal } from "@angular/core";
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Context } from "@ag-ui/client";
|
||||
import { connectAgentContext } from "./agent-context";
|
||||
import { CopilotKit } from "./copilotkit";
|
||||
|
||||
class CopilotKitCoreStub {
|
||||
addContext = vi.fn<(context: Context) => string>();
|
||||
removeContext = vi.fn<(id: string) => void>();
|
||||
|
||||
constructor() {
|
||||
this.addContext.mockImplementation(
|
||||
() => `ctx-${this.addContext.mock.calls.length}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CopilotKitStub {
|
||||
core = new CopilotKitCoreStub();
|
||||
}
|
||||
|
||||
describe("connectAgentContext", () => {
|
||||
let core: CopilotKitCoreStub;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [{ provide: CopilotKit, useClass: CopilotKitStub }],
|
||||
});
|
||||
|
||||
core = TestBed.inject(CopilotKit).core as unknown as CopilotKitCoreStub;
|
||||
core.addContext.mockClear();
|
||||
core.removeContext.mockClear();
|
||||
});
|
||||
|
||||
it("registers context values and cleans up when the signal changes", async () => {
|
||||
@Component({
|
||||
standalone: true,
|
||||
template: "",
|
||||
})
|
||||
class HostComponent {
|
||||
context = signal<Context>({ description: "Initial", value: "1" });
|
||||
|
||||
constructor() {
|
||||
connectAgentContext(this.context);
|
||||
}
|
||||
}
|
||||
|
||||
const fixture = TestBed.createComponent(HostComponent);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(core.addContext).toHaveBeenNthCalledWith(1, {
|
||||
description: "Initial",
|
||||
value: "1",
|
||||
});
|
||||
|
||||
fixture.componentInstance.context.set({
|
||||
description: "Updated",
|
||||
value: "2",
|
||||
});
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(core.removeContext).toHaveBeenNthCalledWith(1, "ctx-1");
|
||||
expect(core.addContext).toHaveBeenNthCalledWith(2, {
|
||||
description: "Updated",
|
||||
value: "2",
|
||||
});
|
||||
|
||||
fixture.destroy();
|
||||
expect(core.removeContext).toHaveBeenNthCalledWith(2, "ctx-2");
|
||||
});
|
||||
|
||||
it("throws when used outside of an injection context", () => {
|
||||
expect(() =>
|
||||
connectAgentContext({ description: "missing", value: "0" }),
|
||||
).toThrow(/NG0203/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
effect,
|
||||
inject,
|
||||
Injector,
|
||||
runInInjectionContext,
|
||||
Signal,
|
||||
} from "@angular/core";
|
||||
import { Context } from "@ag-ui/client";
|
||||
import { CopilotKit } from "./copilotkit";
|
||||
|
||||
export interface ConnectAgentContextConfig {
|
||||
injector?: Injector;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects context to the agent.
|
||||
*
|
||||
* @param context - The context (or a signal of context) to connect to the agent.
|
||||
* @param config - Optional configuration for connecting the context.
|
||||
*/
|
||||
export function connectAgentContext(
|
||||
context: Context | Signal<Context>,
|
||||
config?: ConnectAgentContextConfig,
|
||||
) {
|
||||
const injector = inject(Injector, { optional: true }) ?? config?.injector;
|
||||
|
||||
if (!injector) {
|
||||
throw new Error(
|
||||
"Injector not found. You must call connectAgentContext in an injector context or pass an injector in the config",
|
||||
);
|
||||
}
|
||||
|
||||
runInInjectionContext(injector, () => {
|
||||
const copilotkit = inject(CopilotKit);
|
||||
|
||||
effect((teardown) => {
|
||||
const contextValue = typeof context === "function" ? context() : context;
|
||||
const id = copilotkit.core.addContext(contextValue);
|
||||
|
||||
teardown(() => {
|
||||
copilotkit.core.removeContext(id);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
Input,
|
||||
signal,
|
||||
} from "@angular/core";
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AbstractAgent } from "@ag-ui/client";
|
||||
import type {
|
||||
AgentSubscriber,
|
||||
BaseEvent,
|
||||
Message,
|
||||
RunAgentInput,
|
||||
State,
|
||||
} from "@ag-ui/client";
|
||||
import { Observable } from "rxjs";
|
||||
import { AgentStore, injectAgentStore } from "./agent";
|
||||
import { CopilotKit } from "./copilotkit";
|
||||
import {
|
||||
CopilotKitCore,
|
||||
ProxiedCopilotRuntimeAgent,
|
||||
CopilotKitCoreRuntimeConnectionStatus,
|
||||
} from "@copilotkit/core";
|
||||
|
||||
/** Shape of the `core` property on the stub — derived from CopilotKitCore
|
||||
* via Pick so the fields stay in sync with the real class. */
|
||||
type StubCore = Pick<
|
||||
CopilotKitCore,
|
||||
| "runtimeUrl"
|
||||
| "runtimeTransport"
|
||||
| "runtimeConnectionStatus"
|
||||
| "headers"
|
||||
| "subscribeToAgentWithOptions"
|
||||
> & {
|
||||
agents?: Record<string, AbstractAgent>;
|
||||
};
|
||||
|
||||
const DUMMY_RUN_INPUT: RunAgentInput = {
|
||||
threadId: "",
|
||||
runId: "",
|
||||
state: {},
|
||||
messages: [],
|
||||
tools: [],
|
||||
context: [],
|
||||
forwardedProps: {},
|
||||
};
|
||||
|
||||
function userMsg(id: string, content: string): Message {
|
||||
return { id, role: "user" as const, content };
|
||||
}
|
||||
|
||||
class MockAgent extends AbstractAgent {
|
||||
unsubscribeCount = 0;
|
||||
|
||||
constructor(id: string) {
|
||||
super();
|
||||
this.agentId = id;
|
||||
}
|
||||
|
||||
run(_input: RunAgentInput): Observable<BaseEvent> {
|
||||
return new Observable();
|
||||
}
|
||||
|
||||
override subscribe(subscriber: AgentSubscriber) {
|
||||
const sub = super.subscribe(subscriber);
|
||||
return {
|
||||
unsubscribe: () => {
|
||||
sub.unsubscribe();
|
||||
this.unsubscribeCount += 1;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
emitMessages(messages: Message[]) {
|
||||
this.messages = messages;
|
||||
for (const s of this.subscribers) {
|
||||
s.onMessagesChanged?.({
|
||||
messages: this.messages,
|
||||
state: this.state,
|
||||
agent: this,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Mirrors AbstractAgent.addMessage: mutate the messages array in place and
|
||||
* notify with the SAME array reference (no reassignment). */
|
||||
pushMessageInPlace(message: Message) {
|
||||
this.messages.push(message);
|
||||
for (const s of this.subscribers) {
|
||||
s.onMessagesChanged?.({
|
||||
messages: this.messages,
|
||||
state: this.state,
|
||||
agent: this,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
emitState(state: State) {
|
||||
this.state = state;
|
||||
for (const s of this.subscribers) {
|
||||
s.onStateChanged?.({
|
||||
messages: this.messages,
|
||||
state: this.state,
|
||||
agent: this,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
emitRunInitialized() {
|
||||
for (const s of this.subscribers) {
|
||||
s.onRunInitialized?.({
|
||||
messages: this.messages,
|
||||
state: this.state,
|
||||
agent: this,
|
||||
input: DUMMY_RUN_INPUT,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
emitRunFinalized() {
|
||||
for (const s of this.subscribers) {
|
||||
s.onRunFinalized?.({
|
||||
messages: this.messages,
|
||||
state: this.state,
|
||||
agent: this,
|
||||
input: DUMMY_RUN_INPUT,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
emitRunFailed() {
|
||||
for (const s of this.subscribers) {
|
||||
s.onRunFailed?.({
|
||||
messages: this.messages,
|
||||
state: this.state,
|
||||
agent: this,
|
||||
input: DUMMY_RUN_INPUT,
|
||||
error: new Error("run failed"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CopilotKitStub {
|
||||
readonly #agents = signal<Record<string, AbstractAgent>>({});
|
||||
readonly #runtimeConnectionStatus =
|
||||
signal<CopilotKitCoreRuntimeConnectionStatus>(
|
||||
CopilotKitCoreRuntimeConnectionStatus.Disconnected,
|
||||
);
|
||||
readonly #runtimeUrl = signal<string | undefined>(undefined);
|
||||
readonly #runtimeTransport = signal<"rest" | "single" | "auto">("auto");
|
||||
readonly #headers = signal<Record<string, string>>({});
|
||||
getAgent = vi.fn((id: string) => this.#agents()[id]);
|
||||
agents = this.#agents.asReadonly();
|
||||
runtimeConnectionStatus = this.#runtimeConnectionStatus.asReadonly();
|
||||
runtimeUrl = this.#runtimeUrl.asReadonly();
|
||||
runtimeTransport = this.#runtimeTransport.asReadonly();
|
||||
headers = this.#headers.asReadonly();
|
||||
#coreInstance = new CopilotKitCore({});
|
||||
core: StubCore = {
|
||||
runtimeUrl: undefined,
|
||||
runtimeTransport: "auto",
|
||||
runtimeConnectionStatus: CopilotKitCoreRuntimeConnectionStatus.Disconnected,
|
||||
headers: {},
|
||||
subscribeToAgentWithOptions:
|
||||
this.#coreInstance.subscribeToAgentWithOptions.bind(this.#coreInstance),
|
||||
};
|
||||
|
||||
setAgents(map: Record<string, AbstractAgent>) {
|
||||
this.#agents.set(map);
|
||||
this.core = { ...this.core, agents: map };
|
||||
}
|
||||
|
||||
setRuntimeConnectionStatus(value: CopilotKitCoreRuntimeConnectionStatus) {
|
||||
this.#runtimeConnectionStatus.set(value);
|
||||
this.core = { ...this.core, runtimeConnectionStatus: value };
|
||||
}
|
||||
|
||||
setRuntimeUrl(value: string | undefined) {
|
||||
this.#runtimeUrl.set(value);
|
||||
this.core = { ...this.core, runtimeUrl: value };
|
||||
}
|
||||
|
||||
setHeaders(value: Record<string, string>) {
|
||||
this.#headers.set(value);
|
||||
this.core = { ...this.core, headers: value };
|
||||
}
|
||||
|
||||
setRuntimeTransport(value: "rest" | "single" | "auto") {
|
||||
this.#runtimeTransport.set(value);
|
||||
this.core = { ...this.core, runtimeTransport: value };
|
||||
}
|
||||
}
|
||||
|
||||
describe("injectAgentStore", () => {
|
||||
let copilotKitStub: CopilotKitStub;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.resetTestingModule();
|
||||
copilotKitStub = new CopilotKitStub();
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [{ provide: CopilotKit, useValue: copilotKitStub }],
|
||||
});
|
||||
});
|
||||
|
||||
it("creates AgentStore instances that mirror agent events", () => {
|
||||
const agent = new MockAgent("agent-1");
|
||||
copilotKitStub.setAgents({ "agent-1": agent });
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
template: "",
|
||||
})
|
||||
class ConstantAgentHost {
|
||||
store = injectAgentStore("agent-1");
|
||||
}
|
||||
|
||||
const fixture = TestBed.createComponent(ConstantAgentHost);
|
||||
fixture.detectChanges();
|
||||
|
||||
const store = fixture.componentInstance.store();
|
||||
expect(store).toBeInstanceOf(AgentStore);
|
||||
expect(store?.agent).toBe(agent);
|
||||
|
||||
agent.emitMessages([userMsg("1", "Hello")]);
|
||||
expect(store?.messages()).toEqual([userMsg("1", "Hello")]);
|
||||
|
||||
agent.emitState({ loaded: true });
|
||||
expect(store?.state()).toEqual({ loaded: true });
|
||||
|
||||
agent.emitRunInitialized();
|
||||
expect(store?.isRunning()).toBe(true);
|
||||
|
||||
agent.emitRunFailed();
|
||||
expect(store?.isRunning()).toBe(false);
|
||||
});
|
||||
|
||||
it("disposes previous store when agent id changes and cleans up on destroy", () => {
|
||||
const firstAgent = new MockAgent("agent-1");
|
||||
const secondAgent = new MockAgent("agent-2");
|
||||
|
||||
copilotKitStub.setAgents({
|
||||
"agent-1": firstAgent,
|
||||
"agent-2": secondAgent,
|
||||
});
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
template: "",
|
||||
})
|
||||
class HostComponent {
|
||||
agentId = signal<string | undefined>("agent-1");
|
||||
store = injectAgentStore(this.agentId);
|
||||
}
|
||||
|
||||
const fixture = TestBed.createComponent(HostComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.store()?.agent).toBe(firstAgent);
|
||||
|
||||
fixture.componentInstance.agentId.set("agent-2");
|
||||
copilotKitStub.setAgents({
|
||||
"agent-1": firstAgent,
|
||||
"agent-2": secondAgent,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.componentInstance.store()?.agent).toBe(secondAgent);
|
||||
expect(firstAgent.unsubscribeCount).toBe(1);
|
||||
|
||||
fixture.destroy();
|
||||
expect(secondAgent.unsubscribeCount).toBe(1);
|
||||
});
|
||||
|
||||
it("returns a proxied AgentStore while runtime is connecting", () => {
|
||||
copilotKitStub.setAgents({});
|
||||
copilotKitStub.setRuntimeUrl("https://runtime.local");
|
||||
copilotKitStub.setHeaders({ "x-test": "1" });
|
||||
copilotKitStub.setRuntimeConnectionStatus(
|
||||
CopilotKitCoreRuntimeConnectionStatus.Connecting,
|
||||
);
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
template: "",
|
||||
})
|
||||
class MissingAgentHost {
|
||||
store = injectAgentStore("missing");
|
||||
}
|
||||
|
||||
const fixture = TestBed.createComponent(MissingAgentHost);
|
||||
fixture.detectChanges();
|
||||
|
||||
const store = fixture.componentInstance.store();
|
||||
expect(store).toBeInstanceOf(AgentStore);
|
||||
|
||||
const proxied = store.agent;
|
||||
expect(proxied).toBeInstanceOf(ProxiedCopilotRuntimeAgent);
|
||||
// Single narrowing after the instanceof assertion above
|
||||
const proxiedAgent = proxied as ProxiedCopilotRuntimeAgent;
|
||||
expect(proxiedAgent.agentId).toBe("missing");
|
||||
expect(proxiedAgent.headers).toEqual({ "x-test": "1" });
|
||||
});
|
||||
|
||||
it("throws when agent cannot be resolved after runtime sync", () => {
|
||||
copilotKitStub.setAgents({});
|
||||
copilotKitStub.setRuntimeUrl("https://runtime.local");
|
||||
copilotKitStub.setRuntimeConnectionStatus(
|
||||
CopilotKitCoreRuntimeConnectionStatus.Connected,
|
||||
);
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
template: "",
|
||||
})
|
||||
class MissingAgentHost {
|
||||
store = injectAgentStore("missing");
|
||||
}
|
||||
|
||||
const fixture = TestBed.createComponent(MissingAgentHost);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(() => fixture.componentInstance.store()).toThrowError(
|
||||
/injectAgentStore: Agent 'missing' not found after runtime sync/,
|
||||
);
|
||||
});
|
||||
|
||||
// Regression: issue #5416. AbstractAgent.addMessage mutates its messages
|
||||
// array in place and notifies with the same reference; the store must not
|
||||
// forward that live reference, or the signal's Object.is check makes set()
|
||||
// a no-op and OnPush views never re-render until the run finishes.
|
||||
it("exposes a fresh array reference, not the agent's live messages array", () => {
|
||||
const agent = new MockAgent("agent-1");
|
||||
copilotKitStub.setAgents({ "agent-1": agent });
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
template: "",
|
||||
})
|
||||
class Host {
|
||||
store = injectAgentStore("agent-1");
|
||||
}
|
||||
|
||||
const fixture = TestBed.createComponent(Host);
|
||||
fixture.detectChanges();
|
||||
const store = fixture.componentInstance.store();
|
||||
|
||||
agent.pushMessageInPlace(userMsg("1", "Hello"));
|
||||
|
||||
expect(store.messages()).toEqual([userMsg("1", "Hello")]);
|
||||
expect(store.messages()).not.toBe(agent.messages);
|
||||
});
|
||||
|
||||
it("re-renders an OnPush view when messages are mutated in place", () => {
|
||||
const agent = new MockAgent("agent-1");
|
||||
copilotKitStub.setAgents({ "agent-1": agent });
|
||||
|
||||
@Component({
|
||||
selector: "message-count",
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
{{ store.messages().length }}
|
||||
`,
|
||||
})
|
||||
class MessageCount {
|
||||
@Input({ required: true }) store!: AgentStore;
|
||||
}
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [MessageCount],
|
||||
template: `
|
||||
<message-count [store]="store()" />
|
||||
`,
|
||||
})
|
||||
class Host {
|
||||
store = injectAgentStore("agent-1");
|
||||
}
|
||||
|
||||
const fixture = TestBed.createComponent(Host);
|
||||
fixture.detectChanges();
|
||||
const rendered = () => fixture.nativeElement.textContent.trim();
|
||||
expect(rendered()).toBe("0");
|
||||
|
||||
// First in-place push: the signal's reference differs from its initial
|
||||
// value, so this notifies even with the bug present.
|
||||
agent.pushMessageInPlace(userMsg("1", "Hello"));
|
||||
fixture.detectChanges();
|
||||
expect(rendered()).toBe("1");
|
||||
|
||||
// Second in-place push reuses the array reference the signal now holds.
|
||||
// Without the shallow copy this is an Object.is no-op: the signal never
|
||||
// notifies, the OnPush child stays clean, and the count stays at "1".
|
||||
agent.pushMessageInPlace(userMsg("2", "World"));
|
||||
fixture.detectChanges();
|
||||
expect(rendered()).toBe("2");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import type { Signal } from "@angular/core";
|
||||
import {
|
||||
DestroyRef,
|
||||
Injectable,
|
||||
inject,
|
||||
signal,
|
||||
computed,
|
||||
} from "@angular/core";
|
||||
import { CopilotKit } from "./copilotkit";
|
||||
import type { AbstractAgent } from "@ag-ui/client";
|
||||
import type { Message } from "@ag-ui/client";
|
||||
import { DEFAULT_AGENT_ID } from "@copilotkit/shared";
|
||||
import type { CopilotKitCore } from "@copilotkit/core";
|
||||
import {
|
||||
ProxiedCopilotRuntimeAgent,
|
||||
CopilotKitCoreRuntimeConnectionStatus,
|
||||
} from "@copilotkit/core";
|
||||
|
||||
/** Function signature for subscribing to an agent — derived from
|
||||
* CopilotKitCore so the types stay in sync automatically. Injected
|
||||
* by the factory so that AgentStore stays decoupled from the concrete class. */
|
||||
type SubscribeToAgentFn = CopilotKitCore["subscribeToAgentWithOptions"];
|
||||
type AgentWithHeaders = AbstractAgent & { headers?: Record<string, string> };
|
||||
|
||||
function hasAgentHeaders(agent: AbstractAgent): agent is AgentWithHeaders {
|
||||
return "headers" in agent;
|
||||
}
|
||||
|
||||
export class AgentStore {
|
||||
readonly #subscription?: {
|
||||
unsubscribe: () => void;
|
||||
};
|
||||
readonly #isRunning = signal<boolean>(false);
|
||||
readonly #messages = signal<Message[]>([]);
|
||||
readonly #state = signal<unknown>(undefined);
|
||||
|
||||
readonly agent: AbstractAgent;
|
||||
readonly isRunning = this.#isRunning.asReadonly();
|
||||
readonly messages = this.#messages.asReadonly();
|
||||
readonly state = this.#state.asReadonly();
|
||||
|
||||
constructor(
|
||||
abstractAgent: AbstractAgent,
|
||||
destroyRef: DestroyRef,
|
||||
subscribeToAgent: SubscribeToAgentFn,
|
||||
) {
|
||||
this.agent = abstractAgent;
|
||||
|
||||
this.#subscription = subscribeToAgent(abstractAgent, {
|
||||
onMessagesChanged: () => {
|
||||
this.#messages.set([...abstractAgent.messages]);
|
||||
},
|
||||
onStateChanged: () => {
|
||||
this.#state.set(abstractAgent.state);
|
||||
},
|
||||
onRunInitialized: () => {
|
||||
this.#isRunning.set(true);
|
||||
},
|
||||
onRunFinalized: () => {
|
||||
this.#isRunning.set(false);
|
||||
},
|
||||
onRunFailed: () => {
|
||||
this.#isRunning.set(false);
|
||||
},
|
||||
// Protocol-level RUN_ERROR event (distinct from onRunFailed which
|
||||
// handles local exceptions like network errors).
|
||||
onRunErrorEvent: () => {
|
||||
this.#isRunning.set(false);
|
||||
},
|
||||
});
|
||||
|
||||
destroyRef.onDestroy(() => {
|
||||
this.teardown();
|
||||
});
|
||||
}
|
||||
|
||||
teardown(): void {
|
||||
if (this.#subscription) {
|
||||
this.#subscription.unsubscribe();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: "root" })
|
||||
export class CopilotkitAgentFactory {
|
||||
readonly #copilotkit = inject(CopilotKit);
|
||||
|
||||
createAgentStoreSignal(
|
||||
agentId: Signal<string | undefined>,
|
||||
destroyRef: DestroyRef,
|
||||
): Signal<AgentStore> {
|
||||
let lastAgentStore: AgentStore | undefined;
|
||||
let lastAgent: AbstractAgent | undefined;
|
||||
const provisionalCache = new Map<string, ProxiedCopilotRuntimeAgent>();
|
||||
const subscribeToAgent: SubscribeToAgentFn =
|
||||
this.#copilotkit.core.subscribeToAgentWithOptions.bind(
|
||||
this.#copilotkit.core,
|
||||
);
|
||||
|
||||
const resolveAgent = (): AbstractAgent => {
|
||||
const resolvedAgentId = agentId() || DEFAULT_AGENT_ID;
|
||||
const existing = this.#copilotkit.getAgent(resolvedAgentId);
|
||||
if (existing) {
|
||||
provisionalCache.delete(resolvedAgentId);
|
||||
return existing;
|
||||
}
|
||||
|
||||
const runtimeUrl = this.#copilotkit.runtimeUrl();
|
||||
const isRuntimeConfigured = runtimeUrl !== undefined;
|
||||
const { runtimeConnectionStatus } = this.#copilotkit.core;
|
||||
|
||||
if (
|
||||
isRuntimeConfigured &&
|
||||
(runtimeConnectionStatus ===
|
||||
CopilotKitCoreRuntimeConnectionStatus.Disconnected ||
|
||||
runtimeConnectionStatus ===
|
||||
CopilotKitCoreRuntimeConnectionStatus.Connecting ||
|
||||
runtimeConnectionStatus ===
|
||||
CopilotKitCoreRuntimeConnectionStatus.Error)
|
||||
) {
|
||||
const headers = this.#copilotkit.headers();
|
||||
const cached = provisionalCache.get(resolvedAgentId);
|
||||
if (cached) {
|
||||
if (hasAgentHeaders(cached)) {
|
||||
cached.headers = { ...headers };
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
const provisional = new ProxiedCopilotRuntimeAgent({
|
||||
runtimeUrl,
|
||||
agentId: resolvedAgentId,
|
||||
transport: this.#copilotkit.runtimeTransport(),
|
||||
});
|
||||
if (hasAgentHeaders(provisional)) {
|
||||
provisional.headers = { ...headers };
|
||||
}
|
||||
provisionalCache.set(resolvedAgentId, provisional);
|
||||
return provisional;
|
||||
}
|
||||
|
||||
const knownAgents = Object.keys(this.#copilotkit.agents() ?? {});
|
||||
const runtimePart = isRuntimeConfigured
|
||||
? `runtimeUrl=${runtimeUrl}`
|
||||
: "no runtimeUrl";
|
||||
throw new Error(
|
||||
`injectAgentStore: Agent '${resolvedAgentId}' not found after runtime sync (${runtimePart}). ` +
|
||||
(knownAgents.length
|
||||
? `Known agents: [${knownAgents.join(", ")}]`
|
||||
: "No agents registered.") +
|
||||
" Verify your runtime /info and/or agents__unsafe_dev_only.",
|
||||
);
|
||||
};
|
||||
|
||||
return computed(() => {
|
||||
this.#copilotkit.agents();
|
||||
this.#copilotkit.runtimeConnectionStatus();
|
||||
this.#copilotkit.runtimeUrl();
|
||||
this.#copilotkit.runtimeTransport();
|
||||
this.#copilotkit.headers();
|
||||
|
||||
const agent = resolveAgent();
|
||||
|
||||
if (lastAgentStore && lastAgent === agent) {
|
||||
return lastAgentStore;
|
||||
}
|
||||
|
||||
lastAgentStore?.teardown();
|
||||
lastAgent = agent;
|
||||
lastAgentStore = new AgentStore(agent, destroyRef, subscribeToAgent);
|
||||
return lastAgentStore;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function injectAgentStore(
|
||||
agentId: string | Signal<string | undefined>,
|
||||
): Signal<AgentStore> {
|
||||
const agentFactory = inject(CopilotkitAgentFactory);
|
||||
const destroyRef = inject(DestroyRef);
|
||||
const agentIdSignal =
|
||||
typeof agentId === "function" ? agentId : computed(() => agentId);
|
||||
|
||||
return agentFactory.createAgentStoreSignal(agentIdSignal, destroyRef);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { inject, InjectionToken, Provider } from "@angular/core";
|
||||
|
||||
// Type for chat labels
|
||||
export interface CopilotChatLabels {
|
||||
chatInputPlaceholder: string;
|
||||
chatInputToolbarStartTranscribeButtonLabel: string;
|
||||
chatInputToolbarCancelTranscribeButtonLabel: string;
|
||||
chatInputToolbarFinishTranscribeButtonLabel: string;
|
||||
chatInputToolbarAddButtonLabel: string;
|
||||
chatInputToolbarToolsButtonLabel: string;
|
||||
assistantMessageToolbarCopyCodeLabel: string;
|
||||
assistantMessageToolbarCopyCodeCopiedLabel: string;
|
||||
assistantMessageToolbarCopyMessageLabel: string;
|
||||
assistantMessageToolbarThumbsUpLabel: string;
|
||||
assistantMessageToolbarThumbsDownLabel: string;
|
||||
assistantMessageToolbarReadAloudLabel: string;
|
||||
assistantMessageToolbarRegenerateLabel: string;
|
||||
userMessageToolbarCopyMessageLabel: string;
|
||||
userMessageToolbarEditMessageLabel: string;
|
||||
chatDisclaimerText: string;
|
||||
welcomeMessageText: string;
|
||||
}
|
||||
|
||||
// Default labels constant
|
||||
export const COPILOT_CHAT_DEFAULT_LABELS: CopilotChatLabels = {
|
||||
chatInputPlaceholder: "Type a message...",
|
||||
chatInputToolbarStartTranscribeButtonLabel: "Transcribe",
|
||||
chatInputToolbarCancelTranscribeButtonLabel: "Cancel",
|
||||
chatInputToolbarFinishTranscribeButtonLabel: "Finish",
|
||||
chatInputToolbarAddButtonLabel: "Add photos or files",
|
||||
chatInputToolbarToolsButtonLabel: "Tools",
|
||||
assistantMessageToolbarCopyCodeLabel: "Copy",
|
||||
assistantMessageToolbarCopyCodeCopiedLabel: "Copied",
|
||||
assistantMessageToolbarCopyMessageLabel: "Copy",
|
||||
assistantMessageToolbarThumbsUpLabel: "Good response",
|
||||
assistantMessageToolbarThumbsDownLabel: "Bad response",
|
||||
assistantMessageToolbarReadAloudLabel: "Read aloud",
|
||||
assistantMessageToolbarRegenerateLabel: "Regenerate",
|
||||
userMessageToolbarCopyMessageLabel: "Copy",
|
||||
userMessageToolbarEditMessageLabel: "Edit",
|
||||
chatDisclaimerText:
|
||||
"AI can make mistakes. Please verify important information.",
|
||||
welcomeMessageText: "How can I help you today?",
|
||||
};
|
||||
|
||||
export const COPILOT_CHAT_LABELS = new InjectionToken<CopilotChatLabels>(
|
||||
"COPILOT_CHAT_LABELS",
|
||||
);
|
||||
|
||||
export function injectChatLabels(): CopilotChatLabels {
|
||||
return (
|
||||
inject(COPILOT_CHAT_LABELS, { optional: true }) ??
|
||||
COPILOT_CHAT_DEFAULT_LABELS
|
||||
);
|
||||
}
|
||||
|
||||
export function provideCopilotChatLabels(
|
||||
config: Partial<CopilotChatLabels>,
|
||||
): Provider {
|
||||
return {
|
||||
provide: COPILOT_CHAT_LABELS,
|
||||
useValue: {
|
||||
...COPILOT_CHAT_DEFAULT_LABELS,
|
||||
...config,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { inject } from "@angular/core";
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { test, expect } from "vitest";
|
||||
import {
|
||||
CopilotChatConfiguration,
|
||||
provideCopilotChatConfiguration,
|
||||
injectChatConfiguration,
|
||||
} from "./chat-configuration";
|
||||
|
||||
test("provideCopilotChatConfiguration registers an injectable instance", () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideCopilotChatConfiguration()],
|
||||
});
|
||||
const config = TestBed.runInInjectionContext(() => injectChatConfiguration());
|
||||
expect(config).toBeInstanceOf(CopilotChatConfiguration);
|
||||
});
|
||||
|
||||
test("uncontrolled config mints a non-explicit thread by default", () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideCopilotChatConfiguration()],
|
||||
});
|
||||
const c = TestBed.runInInjectionContext(() => injectChatConfiguration());
|
||||
expect(c.threadId()).toBeTruthy();
|
||||
expect(c.hasExplicitThreadId()).toBe(false);
|
||||
});
|
||||
|
||||
test("a caller threadId is controlled + explicit", () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideCopilotChatConfiguration({ threadId: "caller-1" })],
|
||||
});
|
||||
const c = TestBed.runInInjectionContext(() => injectChatConfiguration());
|
||||
expect(c.threadId()).toBe("caller-1");
|
||||
expect(c.hasExplicitThreadId()).toBe(true);
|
||||
});
|
||||
|
||||
test("a non-explicit seed is used but stays non-explicit", () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideCopilotChatConfiguration({
|
||||
threadId: "seed-1",
|
||||
hasExplicitThreadId: false,
|
||||
}),
|
||||
],
|
||||
});
|
||||
const c = TestBed.runInInjectionContext(() => injectChatConfiguration());
|
||||
expect(c.threadId()).toBe("seed-1");
|
||||
expect(c.hasExplicitThreadId()).toBe(false);
|
||||
});
|
||||
|
||||
test("setActiveThreadId switches to an explicit thread", () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideCopilotChatConfiguration()],
|
||||
});
|
||||
const c = TestBed.runInInjectionContext(() => injectChatConfiguration());
|
||||
c.setActiveThreadId("picked-1");
|
||||
expect(c.threadId()).toBe("picked-1");
|
||||
expect(c.hasExplicitThreadId()).toBe(true);
|
||||
});
|
||||
|
||||
test("startNewThread mints a fresh non-explicit thread", () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideCopilotChatConfiguration()],
|
||||
});
|
||||
const c = TestBed.runInInjectionContext(() => injectChatConfiguration());
|
||||
c.setActiveThreadId("picked-1");
|
||||
const before = c.threadId();
|
||||
c.startNewThread();
|
||||
expect(c.threadId()).not.toBe(before);
|
||||
expect(c.hasExplicitThreadId()).toBe(false);
|
||||
});
|
||||
|
||||
test("setters no-op when the threadId is controlled", () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideCopilotChatConfiguration({ threadId: "caller-1" })],
|
||||
});
|
||||
const c = TestBed.runInInjectionContext(() => injectChatConfiguration());
|
||||
c.setActiveThreadId("ignored");
|
||||
expect(c.threadId()).toBe("caller-1");
|
||||
});
|
||||
|
||||
test("drawer registration toggles drawerRegistered and unregisters", () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideCopilotChatConfiguration()],
|
||||
});
|
||||
const c = TestBed.runInInjectionContext(() => injectChatConfiguration());
|
||||
expect(c.drawerRegistered()).toBe(false);
|
||||
const unregister = c.registerDrawer();
|
||||
expect(c.drawerRegistered()).toBe(true);
|
||||
unregister();
|
||||
expect(c.drawerRegistered()).toBe(false);
|
||||
});
|
||||
|
||||
test("drawerOpen is settable", () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideCopilotChatConfiguration()],
|
||||
});
|
||||
const c = TestBed.runInInjectionContext(() => injectChatConfiguration());
|
||||
expect(c.drawerOpen()).toBe(false);
|
||||
c.setDrawerOpen(true);
|
||||
expect(c.drawerOpen()).toBe(true);
|
||||
});
|
||||
|
||||
test("token and direct class injection resolve the same instance", () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideCopilotChatConfiguration()],
|
||||
});
|
||||
const [viaToken, viaClass] = TestBed.runInInjectionContext(() => [
|
||||
injectChatConfiguration(),
|
||||
inject(CopilotChatConfiguration),
|
||||
]);
|
||||
expect(viaToken).toBe(viaClass);
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
import {
|
||||
InjectionToken,
|
||||
Injectable,
|
||||
inject,
|
||||
computed,
|
||||
signal,
|
||||
} from "@angular/core";
|
||||
import type { Provider, Signal } from "@angular/core";
|
||||
import { DEFAULT_AGENT_ID, randomUUID } from "@copilotkit/shared";
|
||||
|
||||
/**
|
||||
* Options for configuring the CopilotChat session.
|
||||
* Mirrors the React CopilotChatConfigurationProvider props so behaviour
|
||||
* is consistent across frameworks.
|
||||
*/
|
||||
export interface CopilotChatConfigurationOptions {
|
||||
/**
|
||||
* The agent id to connect to.
|
||||
* When omitted the runtime's default agent is used.
|
||||
*/
|
||||
agentId?: string;
|
||||
|
||||
/**
|
||||
* An explicit thread id to resume.
|
||||
* When provided the session joins the existing thread instead of starting a
|
||||
* new one.
|
||||
*/
|
||||
threadId?: string;
|
||||
|
||||
/**
|
||||
* Whether the thread id was supplied explicitly by the host application.
|
||||
* Defaults to `false`, which seeds a non-explicit thread id that the runtime
|
||||
* may override.
|
||||
*/
|
||||
hasExplicitThreadId?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Angular service that holds the runtime chat configuration state.
|
||||
*
|
||||
* Active-thread resolution mirrors the React
|
||||
* `CopilotChatConfigurationProvider` precedence (prop → override → minted)
|
||||
* without parent-provider branches (PR1 has no nested providers).
|
||||
*/
|
||||
@Injectable()
|
||||
export class CopilotChatConfiguration {
|
||||
readonly #options = inject(COPILOT_CHAT_CONFIGURATION_OPTIONS);
|
||||
|
||||
/** Imperative override set by internal runtime helpers (e.g. thread-switch). */
|
||||
readonly #override = signal<{ threadId: string; explicit: boolean } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
/**
|
||||
* Auto-minted UUID used when neither a prop-driven nor an imperative
|
||||
* override thread id is present. Generated once per service instance.
|
||||
*/
|
||||
readonly #mintedFallback = randomUUID();
|
||||
|
||||
/**
|
||||
* Whether the host application is driving `threadId` authoritatively.
|
||||
* A prop-driven `threadId` without an explicit `hasExplicitThreadId: false`
|
||||
* override locks the session to that value and prevents imperative overrides.
|
||||
*/
|
||||
readonly #propIsAuthoritative =
|
||||
this.#options.threadId !== undefined &&
|
||||
this.#options.hasExplicitThreadId !== false;
|
||||
|
||||
/**
|
||||
* The resolved agent id for this chat session.
|
||||
* Falls back to {@link DEFAULT_AGENT_ID} when no agent id is provided.
|
||||
*/
|
||||
readonly agentId: Signal<string> = computed(
|
||||
() => this.#options.agentId ?? DEFAULT_AGENT_ID,
|
||||
);
|
||||
|
||||
/**
|
||||
* The resolved thread id for this chat session.
|
||||
*
|
||||
* Precedence (highest → lowest):
|
||||
* 1. Prop-driven `threadId` when `#propIsAuthoritative` is true.
|
||||
* 2. Imperative `#override` set by internal helpers.
|
||||
* 3. Non-authoritative seed `threadId` from options.
|
||||
* 4. Auto-minted UUID fallback.
|
||||
*/
|
||||
readonly threadId: Signal<string> = computed(() => {
|
||||
if (this.#propIsAuthoritative) {
|
||||
return this.#options.threadId as string;
|
||||
}
|
||||
const o = this.#override();
|
||||
if (o) {
|
||||
return o.threadId;
|
||||
}
|
||||
if (this.#options.threadId) {
|
||||
return this.#options.threadId;
|
||||
}
|
||||
return this.#mintedFallback;
|
||||
});
|
||||
|
||||
/**
|
||||
* Whether the current thread id was supplied explicitly by the host
|
||||
* application (as opposed to being auto-minted or seeded without intent).
|
||||
*/
|
||||
readonly hasExplicitThreadId: Signal<boolean> = computed(() => {
|
||||
if (this.#propIsAuthoritative) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
this.#override()?.explicit ?? this.#options.hasExplicitThreadId ?? false
|
||||
);
|
||||
});
|
||||
|
||||
/** Returns `true` when the host application controls the thread id via props. */
|
||||
protected get isControlled(): boolean {
|
||||
return this.#propIsAuthoritative;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets an imperative thread override. No-ops when the session is
|
||||
* controlled by the host application via props.
|
||||
*
|
||||
* @param threadId - The thread id to activate.
|
||||
* @param explicit - Whether the override represents a deliberate user choice.
|
||||
*/
|
||||
protected _setOverride(threadId: string, explicit: boolean): void {
|
||||
if (this.isControlled) {
|
||||
return;
|
||||
}
|
||||
this.#override.set({ threadId, explicit });
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches the active thread to the given id.
|
||||
*
|
||||
* By default the switch is treated as an explicit user-driven choice
|
||||
* (`explicit` defaults to `true`). Pass `{ explicit: false }` to mark it as
|
||||
* a non-explicit seed that the runtime may replace.
|
||||
*
|
||||
* No-ops when the threadId is controlled by the host application via props.
|
||||
*
|
||||
* @param threadId - The thread id to activate.
|
||||
* @param options - Optional overrides; `explicit` defaults to `true`.
|
||||
*/
|
||||
setActiveThreadId(threadId: string, options?: { explicit?: boolean }): void {
|
||||
this._setOverride(threadId, options?.explicit ?? true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Abandons the current thread and starts a fresh one by minting a new UUID.
|
||||
*
|
||||
* The new thread is marked as non-explicit so the runtime may assign a
|
||||
* server-side thread id once the first message is sent.
|
||||
*
|
||||
* No-ops when the threadId is controlled by the host application via props.
|
||||
*/
|
||||
startNewThread(): void {
|
||||
this._setOverride(randomUUID(), false);
|
||||
}
|
||||
|
||||
// ─── Drawer open-state coordination ──────────────────────────────────────
|
||||
// Consumed by CopilotThreadsDrawer to drive the element's controlled `open`
|
||||
// property and to announce drawer presence (so a future header launcher can
|
||||
// render). A future popup/sidebar can additionally use these for mobile
|
||||
// mutual-exclusion between the drawer and other overlays.
|
||||
|
||||
/** Tracks whether the drawer open-state is currently `true`. */
|
||||
readonly #drawerOpen = signal(false);
|
||||
|
||||
/** Count of registered drawer instances. Used to derive {@link drawerRegistered}. */
|
||||
readonly #drawerCount = signal(0);
|
||||
|
||||
/**
|
||||
* Read-only signal reflecting the current drawer open-state.
|
||||
*
|
||||
* @remarks
|
||||
* Consumed by {@link CopilotThreadsDrawer} to drive the element's controlled
|
||||
* `open` property; a future popup or sidebar component may also toggle it to
|
||||
* implement mobile mutual-exclusion between the drawer and other overlays.
|
||||
*/
|
||||
readonly drawerOpen = this.#drawerOpen.asReadonly();
|
||||
|
||||
/**
|
||||
* Sets the drawer open-state imperatively.
|
||||
*
|
||||
* @remarks
|
||||
* Called by {@link CopilotThreadsDrawer} in response to the element's
|
||||
* `open-change` event to keep the shared open-state coordinated.
|
||||
*
|
||||
* @param open - `true` to mark the drawer as open, `false` to close it.
|
||||
*/
|
||||
setDrawerOpen(open: boolean): void {
|
||||
this.#drawerOpen.set(open);
|
||||
}
|
||||
|
||||
/**
|
||||
* Computed signal that is `true` when at least one drawer instance has
|
||||
* registered itself via {@link registerDrawer}.
|
||||
*/
|
||||
readonly drawerRegistered = computed(() => this.#drawerCount() > 0);
|
||||
|
||||
/**
|
||||
* Registers a drawer instance and returns an idempotent unregister function.
|
||||
*
|
||||
* Increments the internal drawer count so that {@link drawerRegistered}
|
||||
* becomes `true`. The returned function decrements the count when called;
|
||||
* calling it more than once is safe — subsequent calls are no-ops (guarded
|
||||
* by an `active` flag, floor at 0).
|
||||
*
|
||||
* @remarks
|
||||
* Called by {@link CopilotThreadsDrawer} on construction with cleanup on
|
||||
* destroy. Intended for future popup/sidebar coordination as well.
|
||||
*
|
||||
* @returns An idempotent cleanup function that unregisters this drawer.
|
||||
*/
|
||||
registerDrawer(): () => void {
|
||||
this.#drawerCount.update((n) => n + 1);
|
||||
let active = true;
|
||||
return () => {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
this.#drawerCount.update((n) => Math.max(0, n - 1));
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection token for the {@link CopilotChatConfiguration} service instance.
|
||||
* Use {@link injectChatConfiguration} to retrieve it from the injector.
|
||||
*/
|
||||
export const COPILOT_CHAT_CONFIGURATION =
|
||||
new InjectionToken<CopilotChatConfiguration>("COPILOT_CHAT_CONFIGURATION");
|
||||
|
||||
/**
|
||||
* Injection token for the raw {@link CopilotChatConfigurationOptions} value
|
||||
* supplied to {@link provideCopilotChatConfiguration}.
|
||||
*/
|
||||
export const COPILOT_CHAT_CONFIGURATION_OPTIONS =
|
||||
new InjectionToken<CopilotChatConfigurationOptions>(
|
||||
"COPILOT_CHAT_CONFIGURATION_OPTIONS",
|
||||
);
|
||||
|
||||
/**
|
||||
* Registers the {@link CopilotChatConfiguration} service and its options into
|
||||
* the current injector.
|
||||
*
|
||||
* `CopilotChatConfiguration` is registered as the canonical class provider.
|
||||
* `COPILOT_CHAT_CONFIGURATION` is aliased to the same instance via
|
||||
* `useExisting`, so both `inject(COPILOT_CHAT_CONFIGURATION)` (via
|
||||
* {@link injectChatConfiguration}) and a direct `inject(CopilotChatConfiguration)`
|
||||
* resolve to the **same object** with shared signal state.
|
||||
*
|
||||
* @param options - Optional chat configuration overrides.
|
||||
* @returns An array of Angular providers to pass to `providers` or
|
||||
* `TestBed.configureTestingModule`.
|
||||
*/
|
||||
export function provideCopilotChatConfiguration(
|
||||
options: CopilotChatConfigurationOptions = {},
|
||||
): Provider[] {
|
||||
return [
|
||||
{ provide: COPILOT_CHAT_CONFIGURATION_OPTIONS, useValue: options },
|
||||
CopilotChatConfiguration,
|
||||
{
|
||||
provide: COPILOT_CHAT_CONFIGURATION,
|
||||
useExisting: CopilotChatConfiguration,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the {@link CopilotChatConfiguration} service from the current
|
||||
* injection context.
|
||||
*
|
||||
* Must be called inside an injection context (component constructor, factory
|
||||
* function, or `TestBed.runInInjectionContext`).
|
||||
*/
|
||||
export function injectChatConfiguration(): CopilotChatConfiguration {
|
||||
return inject(COPILOT_CHAT_CONFIGURATION);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
inject,
|
||||
Injectable,
|
||||
Signal,
|
||||
signal,
|
||||
WritableSignal,
|
||||
} from "@angular/core";
|
||||
import type { Attachment } from "@copilotkit/shared";
|
||||
import type { Suggestion } from "@copilotkit/core";
|
||||
|
||||
@Injectable()
|
||||
export abstract class ChatState {
|
||||
abstract readonly inputValue: WritableSignal<string>;
|
||||
readonly attachments = signal<Attachment[]>([]);
|
||||
readonly attachmentsEnabled: Signal<boolean> = signal(false);
|
||||
readonly attachmentsUploading: Signal<boolean> = signal(false);
|
||||
readonly dragOver = signal(false);
|
||||
readonly suggestions = signal<Suggestion[]>([]);
|
||||
readonly suggestionsLoading = signal(false);
|
||||
readonly isTranscribing = signal(false);
|
||||
|
||||
abstract submitInput(value: string): void;
|
||||
abstract changeInput(value: string): void;
|
||||
selectSuggestion(_suggestion: Suggestion, _index: number): void {}
|
||||
finishTranscription(_audioBlob: Blob): void | Promise<void> {}
|
||||
|
||||
addFile(): void {}
|
||||
removeAttachment(_id: string): void {}
|
||||
handleDragOver(_event: DragEvent): void {}
|
||||
handleDragLeave(_event: DragEvent): void {}
|
||||
handleDrop(_event: DragEvent): void {}
|
||||
}
|
||||
|
||||
export function injectChatState(): ChatState {
|
||||
try {
|
||||
return inject(ChatState);
|
||||
} catch {
|
||||
throw new Error(
|
||||
"ChatState not found. A parent component must provide ChatState.",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { CopilotA2UIActivityRenderer } from "../a2ui-activity-renderer";
|
||||
import { COPILOT_KIT_CONFIG } from "../../../config";
|
||||
import { CopilotKit } from "../../../copilotkit";
|
||||
import type { ActivityMessage } from "@ag-ui/core";
|
||||
|
||||
describe("CopilotA2UIActivityRenderer", () => {
|
||||
let fixture: ComponentFixture<CopilotA2UIActivityRenderer>;
|
||||
let core: {
|
||||
properties: Record<string, unknown>;
|
||||
setProperties: ReturnType<typeof vi.fn>;
|
||||
runAgent: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
const message: ActivityMessage = {
|
||||
id: "activity-1",
|
||||
role: "activity",
|
||||
activityType: "a2ui-surface",
|
||||
content: {
|
||||
a2ui_operations: [{ version: "v0.9", updateComponents: {} }],
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
core = {
|
||||
properties: { existing: true },
|
||||
setProperties: vi.fn((next: Record<string, unknown>) => {
|
||||
core.properties = next;
|
||||
}),
|
||||
runAgent: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
imports: [CopilotA2UIActivityRenderer],
|
||||
providers: [
|
||||
{
|
||||
provide: COPILOT_KIT_CONFIG,
|
||||
useValue: {
|
||||
a2ui: {
|
||||
theme: { color: "blue" },
|
||||
catalog: { id: "catalog" },
|
||||
loadingComponent: () => null,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: CopilotKit,
|
||||
useValue: { core },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
fixture = TestBed.createComponent(CopilotA2UIActivityRenderer);
|
||||
fixture.componentRef.setInput("activityType", "a2ui-surface");
|
||||
fixture.componentRef.setInput("content", message.content);
|
||||
fixture.componentRef.setInput("message", message);
|
||||
fixture.componentRef.setInput("agent", { agentId: "demo-button" });
|
||||
});
|
||||
|
||||
it("lazy-loads web components and assigns complex values as properties", async () => {
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
await customElements.whenDefined("cpk-a2ui-surface");
|
||||
|
||||
const element = fixture.nativeElement.querySelector("cpk-a2ui-surface");
|
||||
const scrollWrapper = fixture.nativeElement.querySelector(
|
||||
'[data-testid="a2ui-activity-surface-scroll"]',
|
||||
) as HTMLElement | null;
|
||||
|
||||
expect(scrollWrapper).not.toBeNull();
|
||||
expect(
|
||||
scrollWrapper?.classList.contains("copilot-a2ui-surface-scroll"),
|
||||
).toBe(true);
|
||||
await vi.waitFor(() =>
|
||||
expect(element.operations).toEqual([
|
||||
{ version: "v0.9", updateComponents: {} },
|
||||
]),
|
||||
);
|
||||
expect(element.theme).toEqual({ color: "blue" });
|
||||
expect(element.catalog).toEqual({ id: "catalog" });
|
||||
expect(element.getAttribute("operations")).toBeNull();
|
||||
expect(element.getAttribute("theme")).toBeNull();
|
||||
expect(element.getAttribute("catalog")).toBeNull();
|
||||
});
|
||||
|
||||
it("bridges a2ui-action through core.runAgent and clears a2uiAction", async () => {
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
const element = fixture.nativeElement.querySelector("cpk-a2ui-surface");
|
||||
element.dispatchEvent(
|
||||
new CustomEvent("a2ui-action", {
|
||||
detail: { userAction: { name: "confirm" } },
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(core.setProperties).toHaveBeenNthCalledWith(1, {
|
||||
existing: true,
|
||||
a2uiAction: { userAction: { name: "confirm" } },
|
||||
});
|
||||
expect(core.runAgent).toHaveBeenCalledWith({
|
||||
agent: { agentId: "demo-button" },
|
||||
});
|
||||
expect(core.setProperties).toHaveBeenLastCalledWith({ existing: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,268 @@
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { CopilotA2UIToolRenderer } from "../a2ui-tool-renderer";
|
||||
import {
|
||||
AGUI_SEND_STATE_SNAPSHOT_TOOL_NAME,
|
||||
type RenderA2UIArgs,
|
||||
} from "../a2ui-tool-types";
|
||||
import { COPILOT_KIT_CONFIG } from "../../../config";
|
||||
import type { AngularToolCall } from "../../../tools";
|
||||
|
||||
type A2UITestSurfaceElement = HTMLElement & {
|
||||
operations?: Array<Record<string, unknown>>;
|
||||
theme?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function setToolCall(
|
||||
fixture: ComponentFixture<CopilotA2UIToolRenderer>,
|
||||
toolCall: AngularToolCall<RenderA2UIArgs>,
|
||||
): void {
|
||||
fixture.componentRef.setInput("toolCall", toolCall);
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
describe("CopilotA2UIToolRenderer", () => {
|
||||
let fixture: ComponentFixture<CopilotA2UIToolRenderer>;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
imports: [CopilotA2UIToolRenderer],
|
||||
providers: [
|
||||
{
|
||||
provide: COPILOT_KIT_CONFIG,
|
||||
useValue: {
|
||||
a2ui: {
|
||||
theme: { color: "blue" },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
fixture = TestBed.createComponent(CopilotA2UIToolRenderer);
|
||||
});
|
||||
|
||||
it("shows progress while render_a2ui is streaming sparse arguments", () => {
|
||||
setToolCall(fixture, {
|
||||
status: "in-progress",
|
||||
args: { surfaceId: "dashboard" },
|
||||
result: undefined,
|
||||
});
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('[data-testid="a2ui-progress"]'),
|
||||
).toBeTruthy();
|
||||
expect(fixture.nativeElement.textContent).toContain("Building interface");
|
||||
});
|
||||
|
||||
it("hides progress once the streamed A2UI surface has enough components", () => {
|
||||
setToolCall(fixture, {
|
||||
status: "in-progress",
|
||||
args: {
|
||||
components: [
|
||||
{ id: "root", component: "Column" },
|
||||
{ id: "title", component: "Text" },
|
||||
{ id: "card", component: "Card" },
|
||||
],
|
||||
},
|
||||
result: undefined,
|
||||
});
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('[data-testid="a2ui-progress"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("hides progress when the tool call is complete", () => {
|
||||
setToolCall(fixture, {
|
||||
status: "complete",
|
||||
args: { surfaceId: "dashboard" },
|
||||
result: "done",
|
||||
});
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('[data-testid="a2ui-progress"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("renders complete A2UI snapshot tool results as a web component surface", async () => {
|
||||
setToolCall(fixture, {
|
||||
status: "complete",
|
||||
args: { surfaceId: "a2ui-dashboard" },
|
||||
result: JSON.stringify({
|
||||
success: true,
|
||||
snapshot: {
|
||||
surfaceId: "a2ui-dashboard",
|
||||
catalogId: "https://a2ui.org/specification/v0_9/basic_catalog.json",
|
||||
data: { settings: { automation: true, performance: 72 } },
|
||||
components: [
|
||||
{ id: "root", component: "Card", child: "title" },
|
||||
{
|
||||
id: "title",
|
||||
component: "Text",
|
||||
text: "Operations Dashboard",
|
||||
variant: "h2",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
await fixture.whenStable();
|
||||
await customElements.whenDefined("cpk-a2ui-surface");
|
||||
|
||||
const surface = fixture.nativeElement.querySelector(
|
||||
"cpk-a2ui-surface",
|
||||
) as A2UITestSurfaceElement | null;
|
||||
const scrollWrapper = fixture.nativeElement.querySelector(
|
||||
'[data-testid="a2ui-tool-surface-scroll"]',
|
||||
) as HTMLElement | null;
|
||||
|
||||
expect(surface).not.toBeNull();
|
||||
expect(scrollWrapper).not.toBeNull();
|
||||
expect(
|
||||
scrollWrapper?.classList.contains("copilot-a2ui-surface-scroll"),
|
||||
).toBe(true);
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('[data-testid="a2ui-progress"]'),
|
||||
).toBeNull();
|
||||
expect(surface?.operations).toEqual([
|
||||
{
|
||||
version: "v0.9",
|
||||
createSurface: {
|
||||
surfaceId: "a2ui-dashboard",
|
||||
catalogId: "https://a2ui.org/specification/v0_9/basic_catalog.json",
|
||||
theme: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
version: "v0.9",
|
||||
updateDataModel: {
|
||||
surfaceId: "a2ui-dashboard",
|
||||
path: "/",
|
||||
value: { settings: { automation: true, performance: 72 } },
|
||||
},
|
||||
},
|
||||
{
|
||||
version: "v0.9",
|
||||
updateComponents: {
|
||||
surfaceId: "a2ui-dashboard",
|
||||
components: [
|
||||
{ id: "root", component: "Card", child: "title" },
|
||||
{
|
||||
id: "title",
|
||||
component: "Text",
|
||||
text: "Operations Dashboard",
|
||||
variant: "h2",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(surface?.theme).toEqual({ color: "blue" });
|
||||
expect(surface?.getAttribute("operations")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders AGUISendStateSnapshot results containing an A2UI snapshot", async () => {
|
||||
setToolCall(fixture, {
|
||||
name: AGUI_SEND_STATE_SNAPSHOT_TOOL_NAME,
|
||||
status: "complete",
|
||||
args: {
|
||||
snapshot: {
|
||||
surfaceId: "a2ui-dashboard",
|
||||
components: [],
|
||||
},
|
||||
},
|
||||
result: JSON.stringify({
|
||||
success: true,
|
||||
snapshot: {
|
||||
surfaceId: "a2ui-dashboard",
|
||||
catalogId: "https://a2ui.org/specification/v0_9/basic_catalog.json",
|
||||
data: { enabled: true },
|
||||
components: [
|
||||
{ id: "root", component: "Card", child: "title" },
|
||||
{
|
||||
id: "title",
|
||||
component: "Text",
|
||||
text: "Operations Dashboard",
|
||||
variant: "h2",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
await fixture.whenStable();
|
||||
await customElements.whenDefined("cpk-a2ui-surface");
|
||||
|
||||
const surface = fixture.nativeElement.querySelector(
|
||||
"cpk-a2ui-surface",
|
||||
) as A2UITestSurfaceElement | null;
|
||||
|
||||
expect(surface).not.toBeNull();
|
||||
expect(surface?.operations?.[0]).toMatchObject({
|
||||
createSurface: {
|
||||
surfaceId: "a2ui-dashboard",
|
||||
},
|
||||
});
|
||||
expect(fixture.nativeElement.textContent).not.toContain(
|
||||
"AGUISendStateSnapshot",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps AGUISendStateSnapshot args in progress until the result is complete", async () => {
|
||||
setToolCall(fixture, {
|
||||
name: AGUI_SEND_STATE_SNAPSHOT_TOOL_NAME,
|
||||
status: "in-progress",
|
||||
args: {
|
||||
snapshot: {
|
||||
surfaceId: "a2ui-dashboard",
|
||||
catalogId: "https://a2ui.org/specification/v0_9/basic_catalog.json",
|
||||
components: [
|
||||
{ id: "root", component: "Card", child: "title" },
|
||||
{
|
||||
id: "title",
|
||||
component: "Text",
|
||||
text: "Streaming Dashboard",
|
||||
variant: "h2",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
result: undefined,
|
||||
});
|
||||
|
||||
const surface = fixture.nativeElement.querySelector(
|
||||
"cpk-a2ui-surface",
|
||||
) as A2UITestSurfaceElement | null;
|
||||
|
||||
expect(surface).toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('[data-testid="a2ui-progress"]'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders complete A2UI operation tool results as a web component surface", async () => {
|
||||
const operations = [
|
||||
{
|
||||
version: "v0.9",
|
||||
updateComponents: {
|
||||
surfaceId: "dashboard",
|
||||
components: [{ id: "root", component: "Text", text: "Dashboard" }],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
setToolCall(fixture, {
|
||||
status: "complete",
|
||||
args: { surfaceId: "dashboard" },
|
||||
result: JSON.stringify({ a2ui_operations: operations }),
|
||||
});
|
||||
await fixture.whenStable();
|
||||
await customElements.whenDefined("cpk-a2ui-surface");
|
||||
|
||||
const surface = fixture.nativeElement.querySelector(
|
||||
"cpk-a2ui-surface",
|
||||
) as A2UITestSurfaceElement | null;
|
||||
|
||||
expect(surface?.operations).toEqual(operations);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
CUSTOM_ELEMENTS_SCHEMA,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
ElementRef,
|
||||
inject,
|
||||
input,
|
||||
viewChild,
|
||||
} from "@angular/core";
|
||||
import type { AbstractAgent, ActivityMessage } from "@ag-ui/client";
|
||||
import type { ActivityRenderer } from "../../activity-renderer";
|
||||
import { CopilotKit } from "../../copilotkit";
|
||||
import { injectCopilotKitConfig } from "../../config";
|
||||
import {
|
||||
bridgeA2UIAction,
|
||||
connectA2UISurface,
|
||||
getA2UIOperations,
|
||||
logA2UIRenderError,
|
||||
type A2UISurfaceElement,
|
||||
} from "./a2ui-surface-host";
|
||||
|
||||
@Component({
|
||||
selector: "copilot-a2ui-activity-renderer",
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
||||
host: {
|
||||
class: "copilot-a2ui-surface-renderer-layout",
|
||||
},
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div
|
||||
class="copilot-a2ui-surface-scroll"
|
||||
data-testid="a2ui-activity-surface-scroll"
|
||||
>
|
||||
<cpk-a2ui-surface
|
||||
#surface
|
||||
class="copilot-a2ui-surface-scroll-surface"
|
||||
(a2ui-action)="handleAction($event)"
|
||||
(a2ui-error)="handleError($event)"
|
||||
></cpk-a2ui-surface>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class CopilotA2UIActivityRenderer implements ActivityRenderer<unknown> {
|
||||
readonly activityType = input.required<string>();
|
||||
readonly content = input.required<unknown>();
|
||||
readonly message = input.required<ActivityMessage>();
|
||||
readonly agent = input<AbstractAgent | undefined>();
|
||||
|
||||
private readonly surfaceRef = viewChild<
|
||||
unknown,
|
||||
ElementRef<A2UISurfaceElement>
|
||||
>("surface", { read: ElementRef });
|
||||
|
||||
private readonly copilotKit = inject(CopilotKit);
|
||||
private readonly config = injectCopilotKitConfig();
|
||||
|
||||
constructor() {
|
||||
connectA2UISurface({
|
||||
surfaceRef: this.surfaceRef,
|
||||
operations: () => getA2UIOperations(this.content()),
|
||||
config: this.config,
|
||||
});
|
||||
}
|
||||
|
||||
protected async handleAction(event: Event): Promise<void> {
|
||||
await bridgeA2UIAction(
|
||||
this.copilotKit,
|
||||
this.agent(),
|
||||
(event as CustomEvent).detail,
|
||||
);
|
||||
}
|
||||
|
||||
protected handleError(event: Event): void {
|
||||
logA2UIRenderError(event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from "@angular/core";
|
||||
|
||||
/**
|
||||
* Presentational loading placeholder shown while an A2UI tool call is still
|
||||
* streaming. Renders an animated skeleton card whose rows reveal as the
|
||||
* estimated token count climbs through `phase`.
|
||||
*/
|
||||
@Component({
|
||||
selector: "copilot-a2ui-progress",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="copilot-a2ui-progress" data-testid="a2ui-progress">
|
||||
<div class="copilot-a2ui-progress-card">
|
||||
<div class="copilot-a2ui-topbar">
|
||||
<div class="copilot-a2ui-dot-group">
|
||||
<span class="copilot-a2ui-dot"></span>
|
||||
<span class="copilot-a2ui-dot"></span>
|
||||
<span class="copilot-a2ui-dot"></span>
|
||||
</div>
|
||||
<span [class]="topbarBarClass()"></span>
|
||||
</div>
|
||||
|
||||
<div class="copilot-a2ui-lines">
|
||||
<div [class]="rowClass(0, 'cpk:[transition-delay:0s]')">
|
||||
<span
|
||||
class="copilot-a2ui-bar cpk:w-[36px] cpk:h-[7px] cpk:bg-[rgba(147,197,253,0.7)] cpk:[animation-delay:0s]"
|
||||
></span>
|
||||
<span
|
||||
class="copilot-a2ui-bar cpk:w-20 cpk:h-[7px] cpk:bg-[rgba(219,234,254,0.8)] cpk:[animation-delay:0.2s]"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<div [class]="rowClass(0, 'cpk:[transition-delay:0.1s]')">
|
||||
<span class="copilot-a2ui-spacer"></span>
|
||||
<span class="copilot-a2ui-dot"></span>
|
||||
<span
|
||||
class="copilot-a2ui-bar cpk:w-[100px] cpk:h-[7px] cpk:bg-[rgba(24,24,27,0.2)] cpk:[animation-delay:0.3s]"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<div [class]="rowClass(1, 'cpk:[transition-delay:0.15s]')">
|
||||
<span class="copilot-a2ui-spacer"></span>
|
||||
<span
|
||||
class="copilot-a2ui-bar cpk:w-12 cpk:h-[7px] cpk:bg-[rgba(24,24,27,0.15)] cpk:[animation-delay:0.1s]"
|
||||
></span>
|
||||
<span
|
||||
class="copilot-a2ui-bar cpk:w-10 cpk:h-[7px] cpk:bg-[rgba(153,246,228,0.6)] cpk:[animation-delay:0.5s]"
|
||||
></span>
|
||||
<span
|
||||
class="copilot-a2ui-bar cpk:w-14 cpk:h-[7px] cpk:bg-[rgba(147,197,253,0.6)] cpk:[animation-delay:0.3s]"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<div [class]="rowClass(1, 'cpk:[transition-delay:0.2s]')">
|
||||
<span class="copilot-a2ui-spacer"></span>
|
||||
<span class="copilot-a2ui-dot"></span>
|
||||
<span
|
||||
class="copilot-a2ui-bar cpk:w-[60px] cpk:h-[7px] cpk:bg-[rgba(24,24,27,0.15)] cpk:[animation-delay:0.4s]"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<div [class]="rowClass(2, 'cpk:[transition-delay:0.25s]')">
|
||||
<span
|
||||
class="copilot-a2ui-bar cpk:w-10 cpk:h-[7px] cpk:bg-[rgba(153,246,228,0.5)] cpk:[animation-delay:0.2s]"
|
||||
></span>
|
||||
<span class="copilot-a2ui-dot"></span>
|
||||
<span
|
||||
class="copilot-a2ui-bar cpk:w-12 cpk:h-[7px] cpk:bg-[rgba(24,24,27,0.15)] cpk:[animation-delay:0.6s]"
|
||||
></span>
|
||||
<span
|
||||
class="copilot-a2ui-bar cpk:w-16 cpk:h-[7px] cpk:bg-[rgba(147,197,253,0.5)] cpk:[animation-delay:0.1s]"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<div [class]="rowClass(2, 'cpk:[transition-delay:0.3s]')">
|
||||
<span
|
||||
class="copilot-a2ui-bar cpk:w-[36px] cpk:h-[7px] cpk:bg-[rgba(147,197,253,0.6)] cpk:[animation-delay:0.5s]"
|
||||
></span>
|
||||
<span
|
||||
class="copilot-a2ui-bar cpk:w-[36px] cpk:h-[7px] cpk:bg-[rgba(24,24,27,0.12)] cpk:[animation-delay:0.7s]"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<div [class]="rowClass(3, 'cpk:[transition-delay:0.35s]')">
|
||||
<span class="copilot-a2ui-dot"></span>
|
||||
<span
|
||||
class="copilot-a2ui-bar cpk:w-11 cpk:h-[7px] cpk:bg-[rgba(24,24,27,0.18)] cpk:[animation-delay:0.3s]"
|
||||
></span>
|
||||
<span class="copilot-a2ui-dot"></span>
|
||||
<span
|
||||
class="copilot-a2ui-bar cpk:w-14 cpk:h-[7px] cpk:bg-[rgba(153,246,228,0.5)] cpk:[animation-delay:0.8s]"
|
||||
></span>
|
||||
<span
|
||||
class="copilot-a2ui-bar cpk:w-12 cpk:h-[7px] cpk:bg-[rgba(147,197,253,0.5)] cpk:[animation-delay:0.4s]"
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="copilot-a2ui-shimmer"></div>
|
||||
</div>
|
||||
|
||||
<div class="copilot-a2ui-label">
|
||||
<span>Building interface</span>
|
||||
@if (tokens() > 0) {
|
||||
<span class="copilot-a2ui-token-count">
|
||||
~{{ tokens().toLocaleString() }} tokens
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.copilot-a2ui-progress {
|
||||
margin: 12px 0;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.copilot-a2ui-progress-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(228, 228, 231, 0.8);
|
||||
background-color: #fff;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
padding: 16px 18px 14px;
|
||||
}
|
||||
|
||||
.copilot-a2ui-topbar,
|
||||
.copilot-a2ui-row,
|
||||
.copilot-a2ui-label,
|
||||
.copilot-a2ui-dot-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.copilot-a2ui-topbar {
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.copilot-a2ui-dot-group,
|
||||
.copilot-a2ui-row {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.copilot-a2ui-lines {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.copilot-a2ui-row {
|
||||
transition-property: opacity;
|
||||
transition-duration: 0.4s;
|
||||
}
|
||||
|
||||
.copilot-a2ui-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 9999px;
|
||||
background-color: #d4d4d8;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.copilot-a2ui-spacer {
|
||||
width: 12px;
|
||||
flex: 0 0 12px;
|
||||
}
|
||||
|
||||
.copilot-a2ui-bar {
|
||||
display: inline-flex;
|
||||
border-radius: 9999px;
|
||||
animation: copilot-a2ui-fade 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.copilot-a2ui-shimmer {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
105deg,
|
||||
transparent 0%,
|
||||
transparent 40%,
|
||||
rgba(255, 255, 255, 0.6) 50%,
|
||||
transparent 60%,
|
||||
transparent 100%
|
||||
);
|
||||
background-size: 250% 100%;
|
||||
animation: copilot-a2ui-sweep 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.copilot-a2ui-label {
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #a1a1aa;
|
||||
}
|
||||
|
||||
.copilot-a2ui-token-count {
|
||||
font-size: 11px;
|
||||
color: #d4d4d8;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@keyframes copilot-a2ui-fade {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes copilot-a2ui-sweep {
|
||||
0% {
|
||||
background-position: 250% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -250% 0;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class CopilotA2UIProgress {
|
||||
readonly phase = input.required<number>();
|
||||
readonly tokens = input(0);
|
||||
|
||||
protected topbarBarClass(): string {
|
||||
return [
|
||||
"copilot-a2ui-bar",
|
||||
"cpk:w-16",
|
||||
"cpk:h-1.5",
|
||||
"cpk:bg-[#e4e4e7]",
|
||||
this.phase() >= 1 ? "cpk:opacity-100" : "cpk:opacity-40",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
protected rowClass(phase: number, delayClass: string): string {
|
||||
return [
|
||||
"copilot-a2ui-row",
|
||||
delayClass,
|
||||
this.phase() >= phase ? "cpk:opacity-100" : "cpk:opacity-0",
|
||||
].join(" ");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import {
|
||||
DestroyRef,
|
||||
ElementRef,
|
||||
afterRenderEffect,
|
||||
inject,
|
||||
type Signal,
|
||||
} from "@angular/core";
|
||||
import type { AbstractAgent } from "@ag-ui/client";
|
||||
import type {
|
||||
Catalog,
|
||||
LitComponentImplementation,
|
||||
LitRenderable,
|
||||
Theme,
|
||||
} from "@copilotkit/a2ui-renderer/web-components";
|
||||
import type { A2UIConfig } from "../../config";
|
||||
|
||||
export const A2UI_OPERATIONS_KEY = "a2ui_operations";
|
||||
|
||||
export type A2UIOperation = Record<string, unknown>;
|
||||
|
||||
export type A2UISurfaceElement = HTMLElement & {
|
||||
operations?: A2UIOperation[];
|
||||
catalog?: Catalog<LitComponentImplementation>;
|
||||
theme?: Theme;
|
||||
loadingComponent?: () => LitRenderable;
|
||||
};
|
||||
|
||||
export type A2UIConfigLike = { a2ui?: A2UIConfig };
|
||||
|
||||
type CopilotKitActionBridge = {
|
||||
core: {
|
||||
properties: Record<string, unknown>;
|
||||
setProperties(properties: Record<string, unknown>): void;
|
||||
runAgent(options: { agent: AbstractAgent }): Promise<unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
let definePromise: Promise<void> | undefined;
|
||||
|
||||
export function defineA2UIWebComponentsOnce(): Promise<void> {
|
||||
definePromise ??=
|
||||
import("@copilotkit/a2ui-renderer/web-components/define").then(
|
||||
async (mod) => {
|
||||
mod.defineA2UIWebComponents();
|
||||
await customElements.whenDefined("cpk-a2ui-surface");
|
||||
await Promise.resolve();
|
||||
},
|
||||
);
|
||||
return definePromise;
|
||||
}
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
export function getA2UIOperations(content: unknown): A2UIOperation[] {
|
||||
if (!isRecord(content)) return [];
|
||||
|
||||
const operations = content[A2UI_OPERATIONS_KEY] ?? content.operations;
|
||||
if (!Array.isArray(operations)) return [];
|
||||
return operations.filter(isRecord);
|
||||
}
|
||||
|
||||
export function syncA2UISurface(
|
||||
element: A2UISurfaceElement | null | undefined,
|
||||
operations: A2UIOperation[],
|
||||
config?: A2UIConfigLike | null,
|
||||
): void {
|
||||
if (!element) return;
|
||||
element.operations = operations;
|
||||
element.catalog = config?.a2ui?.catalog;
|
||||
element.theme = config?.a2ui?.theme;
|
||||
element.loadingComponent = config?.a2ui?.loadingComponent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires a reactive A2UI surface element to its operations source.
|
||||
*
|
||||
* Defines the web components once, then keeps the surface in sync after every
|
||||
* render whenever the `operations` or `surfaceRef` signals change. Must be
|
||||
* called from an injection context (e.g. a component constructor).
|
||||
*/
|
||||
export function connectA2UISurface(options: {
|
||||
surfaceRef: Signal<ElementRef<A2UISurfaceElement> | undefined>;
|
||||
operations: () => A2UIOperation[];
|
||||
config?: A2UIConfigLike | null;
|
||||
}): void {
|
||||
const { surfaceRef, operations, config } = options;
|
||||
let destroyed = false;
|
||||
inject(DestroyRef).onDestroy(() => {
|
||||
destroyed = true;
|
||||
});
|
||||
|
||||
const sync = (element = surfaceRef()?.nativeElement): void => {
|
||||
if (destroyed) return;
|
||||
syncA2UISurface(element, operations(), config);
|
||||
};
|
||||
|
||||
void defineA2UIWebComponentsOnce().then(() => sync());
|
||||
|
||||
afterRenderEffect({
|
||||
write: () => {
|
||||
operations();
|
||||
const surface = surfaceRef();
|
||||
if (!surface) return;
|
||||
sync(surface.nativeElement);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function logA2UIRenderError(event: Event): void {
|
||||
console.warn("[A2UI Angular] render error:", (event as CustomEvent).detail);
|
||||
}
|
||||
|
||||
export async function bridgeA2UIAction(
|
||||
copilotKit: CopilotKitActionBridge | null | undefined,
|
||||
agent: AbstractAgent | undefined,
|
||||
detail: unknown,
|
||||
): Promise<void> {
|
||||
if (!copilotKit || !agent) return;
|
||||
|
||||
try {
|
||||
copilotKit.core.setProperties({
|
||||
...copilotKit.core.properties,
|
||||
a2uiAction: detail,
|
||||
});
|
||||
await copilotKit.core.runAgent({ agent });
|
||||
} finally {
|
||||
const { a2uiAction, ...rest } = copilotKit.core.properties;
|
||||
copilotKit.core.setProperties(rest);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { AngularToolCall } from "../../tools";
|
||||
import {
|
||||
type A2UIOperation,
|
||||
getA2UIOperations,
|
||||
isRecord,
|
||||
} from "./a2ui-surface-host";
|
||||
import {
|
||||
AGUI_SEND_STATE_SNAPSHOT_TOOL_NAME,
|
||||
type RenderA2UIArgs,
|
||||
} from "./a2ui-tool-types";
|
||||
|
||||
const BASIC_CATALOG_ID =
|
||||
"https://a2ui.org/specification/v0_9/basic_catalog.json";
|
||||
|
||||
type A2UISnapshot = {
|
||||
surfaceId: string;
|
||||
catalogId?: string;
|
||||
data?: unknown;
|
||||
components: unknown[];
|
||||
};
|
||||
|
||||
function parseJsonResult(result: string): unknown {
|
||||
try {
|
||||
return JSON.parse(result);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function getSnapshot(payload: unknown): A2UISnapshot | undefined {
|
||||
if (!isRecord(payload)) return undefined;
|
||||
const snapshot = isRecord(payload.snapshot) ? payload.snapshot : payload;
|
||||
if (
|
||||
typeof snapshot.surfaceId !== "string" ||
|
||||
!Array.isArray(snapshot.components)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
surfaceId: snapshot.surfaceId,
|
||||
catalogId:
|
||||
typeof snapshot.catalogId === "string" ? snapshot.catalogId : undefined,
|
||||
data: snapshot.data,
|
||||
components: snapshot.components,
|
||||
};
|
||||
}
|
||||
|
||||
function operationsFromSnapshot(snapshot: A2UISnapshot): A2UIOperation[] {
|
||||
const operations: A2UIOperation[] = [
|
||||
{
|
||||
version: "v0.9",
|
||||
createSurface: {
|
||||
surfaceId: snapshot.surfaceId,
|
||||
catalogId: snapshot.catalogId ?? BASIC_CATALOG_ID,
|
||||
theme: {},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (snapshot.data !== undefined) {
|
||||
operations.push({
|
||||
version: "v0.9",
|
||||
updateDataModel: {
|
||||
surfaceId: snapshot.surfaceId,
|
||||
path: "/",
|
||||
value: snapshot.data,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
operations.push({
|
||||
version: "v0.9",
|
||||
updateComponents: {
|
||||
surfaceId: snapshot.surfaceId,
|
||||
components: snapshot.components,
|
||||
},
|
||||
});
|
||||
|
||||
return operations;
|
||||
}
|
||||
|
||||
function getOperationsFromPayload(payload: unknown): A2UIOperation[] {
|
||||
if (!isRecord(payload)) return [];
|
||||
|
||||
const operations = getA2UIOperations(payload);
|
||||
if (operations.length > 0) return operations;
|
||||
|
||||
const snapshot = getSnapshot(payload);
|
||||
return snapshot ? operationsFromSnapshot(snapshot) : [];
|
||||
}
|
||||
|
||||
function getOperationsFromResult(result: string | undefined): A2UIOperation[] {
|
||||
if (!result) return [];
|
||||
const payload = parseJsonResult(result);
|
||||
return getOperationsFromPayload(payload);
|
||||
}
|
||||
|
||||
export function getRenderedA2UIOperations(
|
||||
toolCall: AngularToolCall<RenderA2UIArgs>,
|
||||
): A2UIOperation[] {
|
||||
const resultOperations = getOperationsFromResult(toolCall.result);
|
||||
if (resultOperations.length > 0) {
|
||||
return resultOperations;
|
||||
}
|
||||
|
||||
if (
|
||||
toolCall.name === AGUI_SEND_STATE_SNAPSHOT_TOOL_NAME &&
|
||||
toolCall.status !== "complete"
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return getOperationsFromPayload(toolCall.args);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import {
|
||||
CUSTOM_ELEMENTS_SCHEMA,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
ElementRef,
|
||||
computed,
|
||||
inject,
|
||||
input,
|
||||
viewChild,
|
||||
} from "@angular/core";
|
||||
import type { AbstractAgent } from "@ag-ui/client";
|
||||
import { COPILOT_KIT_CONFIG } from "../../config";
|
||||
import { CopilotKit } from "../../copilotkit";
|
||||
import type { AngularToolCall, ToolRenderer } from "../../tools";
|
||||
import {
|
||||
bridgeA2UIAction,
|
||||
connectA2UISurface,
|
||||
logA2UIRenderError,
|
||||
type A2UISurfaceElement,
|
||||
} from "./a2ui-surface-host";
|
||||
import { getRenderedA2UIOperations } from "./a2ui-tool-operations";
|
||||
import { CopilotA2UIProgress } from "./a2ui-progress";
|
||||
import {
|
||||
AGUI_SEND_STATE_SNAPSHOT_TOOL_NAME,
|
||||
type RenderA2UIArgs,
|
||||
} from "./a2ui-tool-types";
|
||||
|
||||
@Component({
|
||||
selector: "copilot-a2ui-tool-renderer",
|
||||
imports: [CopilotA2UIProgress],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
||||
host: {
|
||||
class: "copilot-a2ui-surface-renderer-layout",
|
||||
},
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
@if (renderedOperations().length > 0) {
|
||||
<div
|
||||
class="copilot-a2ui-surface-scroll"
|
||||
data-testid="a2ui-tool-surface-scroll"
|
||||
>
|
||||
<cpk-a2ui-surface
|
||||
#surface
|
||||
class="copilot-a2ui-surface-scroll-surface"
|
||||
data-testid="a2ui-tool-surface"
|
||||
(a2ui-action)="handleAction($event)"
|
||||
(a2ui-error)="handleError($event)"
|
||||
></cpk-a2ui-surface>
|
||||
</div>
|
||||
} @else if (!isHidden()) {
|
||||
<copilot-a2ui-progress [phase]="phase()" [tokens]="tokens()" />
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class CopilotA2UIToolRenderer implements ToolRenderer<RenderA2UIArgs> {
|
||||
readonly toolCall = input.required<AngularToolCall<RenderA2UIArgs>>();
|
||||
readonly agent = input<AbstractAgent | undefined>();
|
||||
|
||||
private readonly surfaceRef = viewChild<
|
||||
unknown,
|
||||
ElementRef<A2UISurfaceElement>
|
||||
>("surface", { read: ElementRef });
|
||||
private readonly config = inject(COPILOT_KIT_CONFIG, { optional: true });
|
||||
private readonly copilotKit = inject(CopilotKit, { optional: true });
|
||||
|
||||
protected readonly renderedOperations = computed(() =>
|
||||
getRenderedA2UIOperations(this.toolCall()),
|
||||
);
|
||||
|
||||
protected readonly tokens = computed(() =>
|
||||
Math.round(JSON.stringify(this.toolCall().args ?? {}).length / 4),
|
||||
);
|
||||
|
||||
protected readonly phase = computed(() => {
|
||||
const tokens = this.tokens();
|
||||
if (tokens < 50) return 0;
|
||||
if (tokens < 200) return 1;
|
||||
if (tokens < 400) return 2;
|
||||
return 3;
|
||||
});
|
||||
|
||||
protected readonly isHidden = computed(() => {
|
||||
const toolCall = this.toolCall();
|
||||
if (toolCall.status === "complete") {
|
||||
return this.renderedOperations().length === 0;
|
||||
}
|
||||
|
||||
if (toolCall.name === AGUI_SEND_STATE_SNAPSHOT_TOOL_NAME) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { items, components } = toolCall.args;
|
||||
if (Array.isArray(items) && items.length > 0) return true;
|
||||
return Array.isArray(components) && components.length > 2;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
connectA2UISurface({
|
||||
surfaceRef: this.surfaceRef,
|
||||
operations: this.renderedOperations,
|
||||
config: this.config,
|
||||
});
|
||||
}
|
||||
|
||||
protected handleError(event: Event): void {
|
||||
logA2UIRenderError(event);
|
||||
}
|
||||
|
||||
protected async handleAction(event: Event): Promise<void> {
|
||||
await bridgeA2UIAction(
|
||||
this.copilotKit,
|
||||
this.agent(),
|
||||
(event as CustomEvent).detail,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const RENDER_A2UI_TOOL_NAME = "render_a2ui";
|
||||
export const AGUI_SEND_STATE_SNAPSHOT_TOOL_NAME = "AGUISendStateSnapshot";
|
||||
export const RenderA2UIArgsSchema = z.record(z.string(), z.unknown());
|
||||
|
||||
export interface RenderA2UIArgs extends Record<string, unknown> {
|
||||
items?: unknown[];
|
||||
components?: unknown[];
|
||||
snapshot?: unknown;
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
EnvironmentInjector,
|
||||
Injectable,
|
||||
runInInjectionContext,
|
||||
} from "@angular/core";
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AssistantMessage } from "../copilot-chat-assistant-message.types";
|
||||
import { CopilotChatAssistantMessage } from "../copilot-chat-assistant-message";
|
||||
import { CopilotChatViewHandlers } from "../copilot-chat-view-handlers";
|
||||
|
||||
@Injectable()
|
||||
class ViewHandlersStub extends CopilotChatViewHandlers {
|
||||
constructor() {
|
||||
super();
|
||||
this.hasAssistantThumbsUpHandler.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
const assistantMessage: AssistantMessage = {
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
content: "Assistant message",
|
||||
toolCalls: [
|
||||
{
|
||||
id: "call-1",
|
||||
type: "function",
|
||||
function: { name: "demo", arguments: "{}" },
|
||||
} as any,
|
||||
],
|
||||
};
|
||||
|
||||
describe("CopilotChatAssistantMessage", () => {
|
||||
let injector: EnvironmentInjector;
|
||||
let component: CopilotChatAssistantMessage;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: CopilotChatViewHandlers, useClass: ViewHandlersStub },
|
||||
],
|
||||
});
|
||||
|
||||
injector = TestBed.inject(EnvironmentInjector);
|
||||
component = runInInjectionContext(
|
||||
injector,
|
||||
() => new CopilotChatAssistantMessage(),
|
||||
);
|
||||
(component as any).message = () => assistantMessage;
|
||||
(component as any).messages = () => [assistantMessage];
|
||||
(component as any).isLoading = () => false;
|
||||
});
|
||||
|
||||
it("provides markdown renderer context", () => {
|
||||
expect(component.markdownRendererContext().content).toBe(
|
||||
"Assistant message",
|
||||
);
|
||||
});
|
||||
|
||||
it("exposes tool call context", () => {
|
||||
const context = component.toolCallsViewContext();
|
||||
expect(context.message).toBe(assistantMessage);
|
||||
expect(context.messages).toEqual([assistantMessage]);
|
||||
expect(context.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it("emits thumbs up events", () => {
|
||||
const thumbsUpSpy = vi.fn();
|
||||
component.thumbsUp.subscribe(thumbsUpSpy);
|
||||
|
||||
component.handleThumbsUp();
|
||||
expect(thumbsUpSpy).toHaveBeenCalledWith({ message: assistantMessage });
|
||||
});
|
||||
});
|
||||
+120
@@ -0,0 +1,120 @@
|
||||
import {
|
||||
EnvironmentInjector,
|
||||
Injectable,
|
||||
runInInjectionContext,
|
||||
signal,
|
||||
} from "@angular/core";
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CopilotChatInput } from "../copilot-chat-input";
|
||||
import { ChatState } from "../../../chat-state";
|
||||
|
||||
@Injectable()
|
||||
class ChatStateStub extends ChatState {
|
||||
inputValue = signal("");
|
||||
override readonly attachmentsEnabled = signal(false);
|
||||
override readonly attachmentsUploading = signal(false);
|
||||
submitInput = vi.fn((value: string) => this.inputValue.set(value));
|
||||
changeInput = vi.fn((value: string) => this.inputValue.set(value));
|
||||
addFile = vi.fn();
|
||||
}
|
||||
|
||||
describe("CopilotChatInput", () => {
|
||||
let injector: EnvironmentInjector;
|
||||
let component: CopilotChatInput;
|
||||
let chatState: ChatStateStub;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [{ provide: ChatState, useClass: ChatStateStub }],
|
||||
});
|
||||
|
||||
injector = TestBed.inject(EnvironmentInjector);
|
||||
chatState = TestBed.inject(ChatState) as ChatStateStub;
|
||||
component = runInInjectionContext(injector, () => new CopilotChatInput());
|
||||
|
||||
const textAreaMock = {
|
||||
setValue: vi.fn(),
|
||||
focus: vi.fn(),
|
||||
};
|
||||
const audioRecorderMock = {
|
||||
start: vi.fn().mockResolvedValue(undefined),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
getState: () => "idle",
|
||||
};
|
||||
(component as any).textAreaRef = () => textAreaMock;
|
||||
(component as any).audioRecorderRef = () => audioRecorderMock;
|
||||
});
|
||||
|
||||
it("switches between input and transcribe modes", () => {
|
||||
expect(component.computedMode()).toBe("input");
|
||||
component.handleStartTranscribe();
|
||||
expect(component.computedMode()).toBe("transcribe");
|
||||
component.handleCancelTranscribe();
|
||||
expect(component.computedMode()).toBe("input");
|
||||
});
|
||||
|
||||
it("emits value changes and updates chat state", () => {
|
||||
const valueSpy = vi.fn();
|
||||
component.valueChange.subscribe(valueSpy);
|
||||
|
||||
component.handleValueChange("Hello world");
|
||||
|
||||
expect(valueSpy).toHaveBeenCalledWith("Hello world");
|
||||
expect(chatState.changeInput).toHaveBeenCalledWith("Hello world");
|
||||
});
|
||||
|
||||
it("submits trimmed messages and clears input", () => {
|
||||
const submitSpy = vi.fn();
|
||||
component.submitMessage.subscribe(submitSpy);
|
||||
|
||||
component.handleValueChange(" Do it ");
|
||||
component.send();
|
||||
|
||||
expect(submitSpy).toHaveBeenCalledWith("Do it");
|
||||
expect(chatState.submitInput).toHaveBeenCalledWith("Do it");
|
||||
expect(chatState.changeInput).toHaveBeenLastCalledWith("");
|
||||
expect(component.textAreaRef()?.setValue).toHaveBeenCalledWith("");
|
||||
});
|
||||
|
||||
it("disables send while attachments are uploading", () => {
|
||||
component.handleValueChange("Do it");
|
||||
chatState.attachmentsUploading.set(true);
|
||||
|
||||
expect(component.sendButtonDisabled()).toBe(true);
|
||||
|
||||
component.send();
|
||||
|
||||
expect(chatState.submitInput).not.toHaveBeenCalled();
|
||||
expect(component.textAreaRef()?.setValue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("only opens the file picker when attachments are enabled", () => {
|
||||
const addFileSpy = vi.fn();
|
||||
component.addFile.subscribe(addFileSpy);
|
||||
|
||||
expect(component.addFileButtonDisabled()).toBe(true);
|
||||
|
||||
component.handleAddFile();
|
||||
|
||||
expect(addFileSpy).not.toHaveBeenCalled();
|
||||
expect(chatState.addFile).not.toHaveBeenCalled();
|
||||
|
||||
chatState.attachmentsEnabled.set(true);
|
||||
|
||||
expect(component.addFileButtonDisabled()).toBe(false);
|
||||
|
||||
component.handleAddFile();
|
||||
|
||||
expect(addFileSpy).toHaveBeenCalledOnce();
|
||||
expect(chatState.addFile).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("exposes tools menu through computed signal", () => {
|
||||
(component as any).toolsMenu = () => [
|
||||
{ label: "Example", onSelect: vi.fn() },
|
||||
];
|
||||
expect(component.computedToolsMenu()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { readFileSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
|
||||
/**
|
||||
* Verifies the angular cursor component renders the stable
|
||||
* `copilot-loading-cursor` testid so e2e tests can deterministically detect
|
||||
* the "still loading" state. Mirrors the convention already in place on the
|
||||
* v2 react-core Cursor.
|
||||
*/
|
||||
|
||||
const cursorPath = resolve(__dirname, "../copilot-chat-message-view-cursor.ts");
|
||||
const cursorSrc = readFileSync(cursorPath, "utf-8");
|
||||
|
||||
describe("angular stable testids", () => {
|
||||
it("CopilotChatMessageViewCursor renders the copilot-loading-cursor testid", () => {
|
||||
expect(cursorSrc).toMatch(/data-testid="copilot-loading-cursor"/);
|
||||
});
|
||||
});
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
import {
|
||||
Component,
|
||||
EnvironmentInjector,
|
||||
runInInjectionContext,
|
||||
signal,
|
||||
} from "@angular/core";
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CopilotChatMessageView } from "../copilot-chat-message-view";
|
||||
import type { ActivityMessage, Message, ReasoningMessage } from "@ag-ui/core";
|
||||
import { CopilotKit } from "../../../copilotkit";
|
||||
import { z } from "zod";
|
||||
import { DummyActivityRenderer } from "./dummy-activity-renderer.component";
|
||||
import { FallbackActivityRenderer } from "./fallback-activity-renderer.component";
|
||||
import type { RenderActivityMessageConfig } from "../../../activity-renderer";
|
||||
|
||||
const assistantMessage: Message = {
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
content: "Assistant reply",
|
||||
};
|
||||
|
||||
const userMessage: Message = {
|
||||
id: "user-1",
|
||||
role: "user",
|
||||
content: "User prompt",
|
||||
};
|
||||
|
||||
const reasoningMessage: ReasoningMessage = {
|
||||
id: "reasoning-1",
|
||||
role: "reasoning",
|
||||
content: "**Designing dashboard layout** I should choose the right renderer.",
|
||||
};
|
||||
|
||||
@Component({
|
||||
imports: [CopilotChatMessageView],
|
||||
template: `
|
||||
<copilot-chat-message-view
|
||||
[messages]="messages"
|
||||
[isLoading]="isLoading"
|
||||
[showCursor]="showCursor"
|
||||
/>
|
||||
`,
|
||||
})
|
||||
class MessageViewHostComponent {
|
||||
messages: Message[] = [];
|
||||
isLoading = false;
|
||||
showCursor = false;
|
||||
}
|
||||
|
||||
type MessageViewTestHarness = CopilotChatMessageView & {
|
||||
messages: () => Message[];
|
||||
isLoading: () => boolean;
|
||||
showCursor: () => boolean;
|
||||
agentId: () => string | undefined;
|
||||
resolveActivityRender: (message: ActivityMessage) =>
|
||||
| {
|
||||
component: unknown;
|
||||
inputs: unknown;
|
||||
}
|
||||
| undefined;
|
||||
};
|
||||
|
||||
describe("CopilotChatMessageView", () => {
|
||||
let injector: EnvironmentInjector;
|
||||
let component: CopilotChatMessageView;
|
||||
let harness: MessageViewTestHarness;
|
||||
const renderers = signal<RenderActivityMessageConfig[]>([]);
|
||||
const getAgent = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.resetTestingModule();
|
||||
renderers.set([]);
|
||||
getAgent.mockReset();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{
|
||||
provide: CopilotKit,
|
||||
useValue: {
|
||||
activityMessageRenderConfigs: renderers.asReadonly(),
|
||||
getAgent,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
injector = TestBed.inject(EnvironmentInjector);
|
||||
component = runInInjectionContext(
|
||||
injector,
|
||||
() => new CopilotChatMessageView(),
|
||||
);
|
||||
harness = component as unknown as MessageViewTestHarness;
|
||||
harness.messages = () => [userMessage, assistantMessage];
|
||||
harness.isLoading = () => false;
|
||||
harness.showCursor = () => false;
|
||||
});
|
||||
|
||||
it("merges assistant props for slot overrides", () => {
|
||||
const props = component.mergeAssistantProps(assistantMessage);
|
||||
expect(props.message).toBe(assistantMessage);
|
||||
expect(props.messages).toEqual([userMessage, assistantMessage]);
|
||||
expect(props.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it("merges user props", () => {
|
||||
const props = component.mergeUserProps(userMessage);
|
||||
expect(props.message).toBe(userMessage);
|
||||
});
|
||||
|
||||
it("forwards assistant events", () => {
|
||||
const thumbsUpSpy = vi.fn();
|
||||
component.assistantMessageThumbsUp.subscribe(thumbsUpSpy);
|
||||
|
||||
component.handleAssistantThumbsUp({ message: assistantMessage });
|
||||
expect(thumbsUpSpy).toHaveBeenCalledWith({ message: assistantMessage });
|
||||
});
|
||||
|
||||
it("resolves activity messages with registered renderers", () => {
|
||||
const activityMessage: ActivityMessage = {
|
||||
id: "activity-1",
|
||||
role: "activity",
|
||||
activityType: "a2ui-surface",
|
||||
content: { operations: [] },
|
||||
};
|
||||
const agent = { agentId: "demo-button" };
|
||||
renderers.set([
|
||||
{
|
||||
activityType: "a2ui-surface",
|
||||
content: z.object({ operations: z.array(z.unknown()) }),
|
||||
component: DummyActivityRenderer,
|
||||
},
|
||||
]);
|
||||
getAgent.mockReturnValue(agent);
|
||||
harness.agentId = () => "demo-button";
|
||||
|
||||
const result = harness.resolveActivityRender(activityMessage);
|
||||
|
||||
expect(result?.component).toBe(DummyActivityRenderer);
|
||||
expect(result?.inputs).toEqual({
|
||||
activityType: "a2ui-surface",
|
||||
content: { operations: [] },
|
||||
message: activityMessage,
|
||||
agent,
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers agent-scoped activity renderers before fallback renderers", () => {
|
||||
const activityMessage: ActivityMessage = {
|
||||
id: "activity-1",
|
||||
role: "activity",
|
||||
activityType: "a2ui-surface",
|
||||
content: {},
|
||||
};
|
||||
renderers.set([
|
||||
{
|
||||
activityType: "a2ui-surface",
|
||||
content: z.object({}),
|
||||
component: FallbackActivityRenderer,
|
||||
},
|
||||
{
|
||||
activityType: "a2ui-surface",
|
||||
agentId: "demo-button",
|
||||
content: z.object({}),
|
||||
component: DummyActivityRenderer,
|
||||
},
|
||||
]);
|
||||
harness.agentId = () => "demo-button";
|
||||
|
||||
const result = harness.resolveActivityRender(activityMessage);
|
||||
|
||||
expect(result?.component).toBe(DummyActivityRenderer);
|
||||
});
|
||||
|
||||
it("renders streaming reasoning messages", () => {
|
||||
const fixture = TestBed.createComponent(MessageViewHostComponent);
|
||||
fixture.componentInstance.messages = [userMessage, reasoningMessage];
|
||||
fixture.componentInstance.isLoading = true;
|
||||
fixture.detectChanges();
|
||||
|
||||
const nativeElement: HTMLElement = fixture.nativeElement;
|
||||
const reasoningElement = nativeElement.querySelector<HTMLElement>(
|
||||
'[data-testid="copilot-chat-reasoning-message"]',
|
||||
);
|
||||
expect(reasoningElement).not.toBeNull();
|
||||
expect(nativeElement.textContent).toContain("Thinking…");
|
||||
expect(nativeElement.textContent).toContain(
|
||||
"I should choose the right renderer.",
|
||||
);
|
||||
expect(reasoningElement?.querySelector("strong")?.textContent).toBe(
|
||||
"Designing dashboard layout",
|
||||
);
|
||||
const header = reasoningElement?.querySelector<HTMLButtonElement>("button");
|
||||
const panel = reasoningElement?.querySelector<HTMLElement>(".cpk\\:grid");
|
||||
const chevron = reasoningElement?.querySelector<SVGElement>("svg");
|
||||
expect(header?.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(panel?.style.gridTemplateRows).toBe("1fr");
|
||||
expect(chevron).not.toBeNull();
|
||||
expect(chevron?.classList.contains("cpk:size-3.5")).toBe(true);
|
||||
expect(chevron?.classList.contains("cpk:rotate-90")).toBe(true);
|
||||
expect(
|
||||
(reasoningElement?.textContent ?? "")
|
||||
.split("\n")
|
||||
.map((line) => line.trim()),
|
||||
).not.toContain(">");
|
||||
|
||||
header?.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(header?.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(panel?.style.gridTemplateRows).toBe("0fr");
|
||||
expect(chevron?.classList.contains("cpk:rotate-90")).toBe(false);
|
||||
});
|
||||
|
||||
it("renders completed reasoning collapsed by default", () => {
|
||||
const fixture = TestBed.createComponent(MessageViewHostComponent);
|
||||
fixture.componentInstance.messages = [userMessage, reasoningMessage];
|
||||
fixture.detectChanges();
|
||||
|
||||
const nativeElement: HTMLElement = fixture.nativeElement;
|
||||
const reasoningElement = nativeElement.querySelector<HTMLElement>(
|
||||
'[data-testid="copilot-chat-reasoning-message"]',
|
||||
);
|
||||
const header = reasoningElement?.querySelector<HTMLButtonElement>("button");
|
||||
const panel = reasoningElement?.querySelector<HTMLElement>(".cpk\\:grid");
|
||||
const chevron = reasoningElement?.querySelector<SVGElement>("svg");
|
||||
|
||||
expect(nativeElement.textContent).toContain("Thought for a few seconds");
|
||||
expect(header?.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(panel?.style.gridTemplateRows).toBe("0fr");
|
||||
expect(chevron?.classList.contains("cpk:size-3.5")).toBe(true);
|
||||
|
||||
header?.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(header?.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(panel?.style.gridTemplateRows).toBe("1fr");
|
||||
});
|
||||
|
||||
it("does not render the chat cursor while the latest message is reasoning", () => {
|
||||
const fixture = TestBed.createComponent(MessageViewHostComponent);
|
||||
fixture.componentInstance.messages = [reasoningMessage];
|
||||
fixture.componentInstance.isLoading = true;
|
||||
fixture.componentInstance.showCursor = true;
|
||||
fixture.detectChanges();
|
||||
|
||||
const nativeElement: HTMLElement = fixture.nativeElement;
|
||||
expect(
|
||||
nativeElement.querySelector("copilot-chat-message-view-cursor"),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
EnvironmentInjector,
|
||||
runInInjectionContext,
|
||||
computed,
|
||||
} from "@angular/core";
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CopilotChatUserMessage } from "../copilot-chat-user-message";
|
||||
import type { UserMessage } from "../copilot-chat-user-message.types";
|
||||
|
||||
const sampleMessage: UserMessage = {
|
||||
id: "msg-1",
|
||||
role: "user",
|
||||
content: "Hello from user",
|
||||
};
|
||||
|
||||
describe("CopilotChatUserMessage", () => {
|
||||
let injector: EnvironmentInjector;
|
||||
let component: CopilotChatUserMessage;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({});
|
||||
injector = TestBed.inject(EnvironmentInjector);
|
||||
component = runInInjectionContext(
|
||||
injector,
|
||||
() => new CopilotChatUserMessage(),
|
||||
);
|
||||
(component as any).message = () => sampleMessage;
|
||||
});
|
||||
|
||||
it("emits edit events when handleEdit is invoked", () => {
|
||||
const editSpy = vi.fn();
|
||||
component.editMessage.subscribe(editSpy);
|
||||
|
||||
component.handleEdit();
|
||||
expect(editSpy).toHaveBeenCalledWith({ message: sampleMessage });
|
||||
});
|
||||
|
||||
it("indicates when branch navigation should be shown", () => {
|
||||
(component as any).numberOfBranches = () => 3;
|
||||
component.showBranchNavigation = computed(
|
||||
() => ((component as any).numberOfBranches() ?? 1) > 1,
|
||||
);
|
||||
expect(component.showBranchNavigation()).toBe(true);
|
||||
|
||||
(component as any).numberOfBranches = () => 1;
|
||||
component.showBranchNavigation = computed(
|
||||
() => ((component as any).numberOfBranches() ?? 1) > 1,
|
||||
);
|
||||
expect(component.showBranchNavigation()).toBe(false);
|
||||
});
|
||||
|
||||
it("forwards branch navigation events", () => {
|
||||
const switchSpy = vi.fn();
|
||||
component.switchToBranch.subscribe(switchSpy);
|
||||
|
||||
const payload = {
|
||||
branchIndex: 2,
|
||||
numberOfBranches: 3,
|
||||
message: sampleMessage,
|
||||
};
|
||||
component.handleSwitchToBranch(payload);
|
||||
expect(switchSpy).toHaveBeenCalledWith(payload);
|
||||
});
|
||||
|
||||
it("splits multimodal user content into text and attachment previews", () => {
|
||||
(component as any).message = () => ({
|
||||
id: "msg-2",
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Please inspect this" },
|
||||
{
|
||||
type: "image",
|
||||
source: {
|
||||
type: "data",
|
||||
value: "data:image/png;base64,AAAA",
|
||||
mimeType: "image/png",
|
||||
},
|
||||
metadata: { filename: "chart.png" },
|
||||
},
|
||||
{
|
||||
type: "document",
|
||||
source: {
|
||||
type: "data",
|
||||
value: "data:application/pdf;base64,AAAA",
|
||||
mimeType: "application/pdf",
|
||||
},
|
||||
metadata: { filename: "report.pdf" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(component.flattenedContent()).toBe("Please inspect this");
|
||||
expect(component.mediaParts()).toHaveLength(2);
|
||||
expect(component.filenameFor(component.mediaParts()[0])).toBe("chart.png");
|
||||
expect(component.filenameFor(component.mediaParts()[1])).toBe("report.pdf");
|
||||
});
|
||||
});
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
import { Injectable, signal } from "@angular/core";
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { CopilotChatView } from "../copilot-chat-view";
|
||||
import { ChatState } from "../../../chat-state";
|
||||
import { provideCopilotKit } from "../../../config";
|
||||
import type { Message } from "@ag-ui/core";
|
||||
|
||||
@Injectable()
|
||||
class ChatStateStub extends ChatState {
|
||||
readonly inputValue = signal("");
|
||||
|
||||
submitInput(value: string): void {
|
||||
this.inputValue.set(value);
|
||||
}
|
||||
|
||||
changeInput(value: string): void {
|
||||
this.inputValue.set(value);
|
||||
}
|
||||
}
|
||||
|
||||
describe("CopilotChatView", () => {
|
||||
beforeEach(() => {
|
||||
TestBed.resetTestingModule();
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollTo", {
|
||||
configurable: true,
|
||||
value: () => undefined,
|
||||
});
|
||||
TestBed.configureTestingModule({
|
||||
imports: [CopilotChatView],
|
||||
providers: [
|
||||
provideCopilotKit({
|
||||
licenseKey: "ck_pub_00000000000000000000000000000000",
|
||||
}),
|
||||
{ provide: ChatState, useClass: ChatStateStub },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the React-parity welcome screen for empty stateless chats", () => {
|
||||
const fixture = TestBed.createComponent(CopilotChatView);
|
||||
|
||||
fixture.componentRef.setInput("messages", []);
|
||||
fixture.componentRef.setInput("hasExplicitThreadId", false);
|
||||
fixture.detectChanges();
|
||||
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
expect(
|
||||
element.querySelector('[data-testid="copilot-welcome-screen"]'),
|
||||
).not.toBeNull();
|
||||
expect(element.textContent).toContain("How can I help you today?");
|
||||
});
|
||||
|
||||
it("suppresses the welcome screen when a thread is explicitly selected", () => {
|
||||
const fixture = TestBed.createComponent(CopilotChatView);
|
||||
|
||||
fixture.componentRef.setInput("messages", []);
|
||||
fixture.componentRef.setInput("hasExplicitThreadId", true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
expect(
|
||||
element.querySelector('[data-testid="copilot-welcome-screen"]'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("sizes the default scroll view as the flex child that owns vertical scrolling", () => {
|
||||
const fixture = TestBed.createComponent(CopilotChatView);
|
||||
const messages: Message[] = [
|
||||
{
|
||||
id: "user-1",
|
||||
role: "user",
|
||||
content: "Hello",
|
||||
},
|
||||
];
|
||||
|
||||
fixture.componentRef.setInput("messages", messages);
|
||||
fixture.detectChanges();
|
||||
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
const scrollViewHost = element.querySelector(
|
||||
"copilot-chat-view-scroll-view",
|
||||
);
|
||||
const scrollContainer = scrollViewHost?.querySelector("div");
|
||||
|
||||
expect(scrollViewHost?.classList.contains("cpk:flex-1")).toBe(true);
|
||||
expect(scrollViewHost?.classList.contains("cpk:min-h-0")).toBe(true);
|
||||
expect(scrollContainer?.classList.contains("cpk:flex-1")).toBe(true);
|
||||
expect(scrollContainer?.classList.contains("cpk:min-h-0")).toBe(true);
|
||||
expect(scrollContainer?.classList.contains("cpk:overflow-y-auto")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("reserves React-parity bottom space in the scroll content", async () => {
|
||||
const fixture = TestBed.createComponent(CopilotChatView);
|
||||
const messages: Message[] = [
|
||||
{
|
||||
id: "user-1",
|
||||
role: "user",
|
||||
content: "Hello",
|
||||
},
|
||||
];
|
||||
|
||||
fixture.componentRef.setInput("messages", messages);
|
||||
fixture.detectChanges();
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
const scrollContent = Array.from(
|
||||
element.querySelectorAll("copilot-chat-view-scroll-view div"),
|
||||
).find((node) => node.style.paddingBottom !== "");
|
||||
|
||||
expect(scrollContent?.style.paddingBottom).toBe("32px");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,296 @@
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { test, expect } from "vitest";
|
||||
import { Observable } from "rxjs";
|
||||
import { AbstractAgent } from "@ag-ui/client";
|
||||
import type { BaseEvent, RunAgentInput } from "@ag-ui/client";
|
||||
import { CopilotChat } from "../copilot-chat";
|
||||
import { provideCopilotKit } from "../../../config";
|
||||
import {
|
||||
injectChatConfiguration,
|
||||
provideCopilotChatConfiguration,
|
||||
type CopilotChatConfiguration,
|
||||
} from "../../../chat-configuration";
|
||||
|
||||
/**
|
||||
* Minimal agent stub: `injectAgentStore` resolves it from the configured
|
||||
* agents map and subscribes to it. Its `run` never emits, so connecting is a
|
||||
* no-op for the purposes of welcome-state assertions.
|
||||
*/
|
||||
class MockAgent extends AbstractAgent {
|
||||
constructor(id: string) {
|
||||
super();
|
||||
this.agentId = id;
|
||||
}
|
||||
|
||||
run(_input: RunAgentInput): Observable<BaseEvent> {
|
||||
return new Observable<BaseEvent>();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders {@link CopilotChat} under an ambient
|
||||
* {@link CopilotChatConfiguration} plus a registered default agent.
|
||||
*
|
||||
* @returns The rendered fixture, the chat configuration service, and a
|
||||
* query helper for the welcome screen.
|
||||
*/
|
||||
function setup() {
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollTo", {
|
||||
configurable: true,
|
||||
value: () => undefined,
|
||||
});
|
||||
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
imports: [CopilotChat],
|
||||
providers: [
|
||||
provideCopilotKit({
|
||||
licenseKey: "ck_pub_00000000000000000000000000000000",
|
||||
agents: { default: new MockAgent("default") },
|
||||
}),
|
||||
provideCopilotChatConfiguration(),
|
||||
],
|
||||
});
|
||||
|
||||
const config = TestBed.runInInjectionContext(() =>
|
||||
injectChatConfiguration(),
|
||||
) as CopilotChatConfiguration;
|
||||
|
||||
const fixture = TestBed.createComponent(CopilotChat);
|
||||
fixture.detectChanges();
|
||||
|
||||
const welcomeScreen = () =>
|
||||
(fixture.nativeElement as HTMLElement).querySelector(
|
||||
'[data-testid="copilot-welcome-screen"]',
|
||||
);
|
||||
|
||||
return { fixture, config, welcomeScreen };
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders {@link CopilotChat} with a custom config {@link agentId} and
|
||||
* optional component input overrides, registering an agent for every id used.
|
||||
*
|
||||
* @param configAgentId - The agent id passed to {@link provideCopilotChatConfiguration}.
|
||||
* @param componentAgentId - Optional `[agentId]` input bound on the component.
|
||||
* @param componentThreadId - Optional `[threadId]` input bound on the component.
|
||||
* @returns The rendered fixture and a helper to read the resolved agent id.
|
||||
*/
|
||||
function setupAgentPrecedence({
|
||||
configAgentId,
|
||||
componentAgentId,
|
||||
componentThreadId,
|
||||
configThreadId,
|
||||
}: {
|
||||
configAgentId: string;
|
||||
componentAgentId?: string;
|
||||
componentThreadId?: string;
|
||||
configThreadId?: string;
|
||||
}) {
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollTo", {
|
||||
configurable: true,
|
||||
value: () => undefined,
|
||||
});
|
||||
|
||||
// Register an agent for every id in play so injectAgentStore never throws.
|
||||
const agentIds = Array.from(
|
||||
new Set(
|
||||
[configAgentId, componentAgentId, "default"].filter(
|
||||
(id): id is string => id !== undefined,
|
||||
),
|
||||
),
|
||||
);
|
||||
const agents = Object.fromEntries(
|
||||
agentIds.map((id) => [id, new MockAgent(id)]),
|
||||
);
|
||||
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
imports: [CopilotChat],
|
||||
providers: [
|
||||
provideCopilotKit({
|
||||
licenseKey: "ck_pub_00000000000000000000000000000000",
|
||||
agents,
|
||||
}),
|
||||
provideCopilotChatConfiguration({
|
||||
agentId: configAgentId,
|
||||
...(configThreadId !== undefined ? { threadId: configThreadId } : {}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const config = TestBed.runInInjectionContext(() =>
|
||||
injectChatConfiguration(),
|
||||
) as CopilotChatConfiguration;
|
||||
|
||||
const fixture = TestBed.createComponent(CopilotChat);
|
||||
|
||||
if (componentAgentId !== undefined) {
|
||||
fixture.componentRef.setInput("agentId", componentAgentId);
|
||||
}
|
||||
if (componentThreadId !== undefined) {
|
||||
fixture.componentRef.setInput("threadId", componentThreadId);
|
||||
}
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
/** Returns the agent id of the resolved agent store's agent. */
|
||||
const resolvedAgentId = () =>
|
||||
fixture.componentInstance.agentStore().agent.agentId;
|
||||
|
||||
/** Returns true when the component's hasExplicitThreadId signal is true. */
|
||||
const isThreadExplicit = () =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(fixture.componentInstance as any)["hasExplicitThreadId"]();
|
||||
|
||||
/** Returns the threadId pinned onto the resolved agent. */
|
||||
const agentThreadId = () =>
|
||||
fixture.componentInstance.agentStore().agent.threadId;
|
||||
|
||||
return { fixture, config, resolvedAgentId, isThreadExplicit, agentThreadId };
|
||||
}
|
||||
|
||||
/** Reads the component's protected `showCursor` signal. */
|
||||
function readShowCursor(fixture: { componentInstance: CopilotChat }): boolean {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (fixture.componentInstance as any)["showCursor"]() as boolean;
|
||||
}
|
||||
|
||||
test("shows the loading cursor while the ambient config connects an explicit thread", () => {
|
||||
const { fixture, config } = setup();
|
||||
|
||||
// The MockAgent's run Observable never emits, so the connect stays pending —
|
||||
// the cursor must remain on for the duration of the connect.
|
||||
config.setActiveThreadId("x", { explicit: true });
|
||||
TestBed.flushEffects();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(readShowCursor(fixture)).toBe(true);
|
||||
});
|
||||
|
||||
test("clears the loading cursor once the ambient-config connect settles", async () => {
|
||||
Object.defineProperty(HTMLElement.prototype, "scrollTo", {
|
||||
configurable: true,
|
||||
value: () => undefined,
|
||||
});
|
||||
|
||||
// An agent whose run completes immediately so the connect promise settles,
|
||||
// mirroring the standalone path that clears the cursor when connect settles.
|
||||
class CompletingAgent extends AbstractAgent {
|
||||
constructor(id: string) {
|
||||
super();
|
||||
this.agentId = id;
|
||||
}
|
||||
|
||||
run(_input: RunAgentInput): Observable<BaseEvent> {
|
||||
return new Observable<BaseEvent>((subscriber) => {
|
||||
subscriber.complete();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
imports: [CopilotChat],
|
||||
providers: [
|
||||
provideCopilotKit({
|
||||
licenseKey: "ck_pub_00000000000000000000000000000000",
|
||||
agents: { default: new CompletingAgent("default") },
|
||||
}),
|
||||
provideCopilotChatConfiguration(),
|
||||
],
|
||||
});
|
||||
|
||||
const config = TestBed.runInInjectionContext(() =>
|
||||
injectChatConfiguration(),
|
||||
) as CopilotChatConfiguration;
|
||||
|
||||
const fixture = TestBed.createComponent(CopilotChat);
|
||||
fixture.detectChanges();
|
||||
|
||||
config.setActiveThreadId("x", { explicit: true });
|
||||
TestBed.flushEffects();
|
||||
|
||||
// Let the connect promise (and its `.finally`) settle and clear the cursor,
|
||||
// mirroring the standalone settle-then-clear. The connect resolution spans
|
||||
// macrotasks (agent teardown), so poll across a few.
|
||||
for (let i = 0; i < 10 && readShowCursor(fixture); i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
TestBed.flushEffects();
|
||||
}
|
||||
|
||||
expect(readShowCursor(fixture)).toBe(false);
|
||||
|
||||
// Drain the directive's deferred initial-scroll timer so it does not fire
|
||||
// after the test environment tears down (scrollTo is stubbed; this only
|
||||
// settles the pending macrotask).
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
test("shows the welcome screen while the configuration thread is non-explicit", () => {
|
||||
const { config, welcomeScreen } = setup();
|
||||
|
||||
expect(config.hasExplicitThreadId()).toBe(false);
|
||||
expect(welcomeScreen()).not.toBeNull();
|
||||
});
|
||||
|
||||
test("hides the welcome screen once the configuration activates an explicit thread", () => {
|
||||
const { fixture, config, welcomeScreen } = setup();
|
||||
|
||||
config.setActiveThreadId("x");
|
||||
TestBed.flushEffects();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(config.hasExplicitThreadId()).toBe(true);
|
||||
expect(welcomeScreen()).toBeNull();
|
||||
});
|
||||
|
||||
// ─── A1: component [agentId] input takes precedence over ambient config ───────
|
||||
|
||||
test("component [agentId] input wins over ambient config agentId", () => {
|
||||
const { resolvedAgentId } = setupAgentPrecedence({
|
||||
configAgentId: "cfg-agent",
|
||||
componentAgentId: "input-agent",
|
||||
});
|
||||
|
||||
expect(resolvedAgentId()).toBe("input-agent");
|
||||
});
|
||||
|
||||
test("config agentId drives resolution when component [agentId] is not set", () => {
|
||||
const { resolvedAgentId } = setupAgentPrecedence({
|
||||
configAgentId: "cfg-agent",
|
||||
});
|
||||
|
||||
expect(resolvedAgentId()).toBe("cfg-agent");
|
||||
});
|
||||
|
||||
// ─── A5: component [threadId] input seeds the ambient config ──────────────────
|
||||
|
||||
test("[threadId] input drives the config thread and agent under an uncontrolled config", () => {
|
||||
const { fixture, config, isThreadExplicit, agentThreadId } =
|
||||
setupAgentPrecedence({
|
||||
configAgentId: "cfg-agent",
|
||||
componentThreadId: "t1",
|
||||
});
|
||||
|
||||
TestBed.flushEffects();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(config.threadId()).toBe("t1");
|
||||
expect(agentThreadId()).toBe("t1");
|
||||
expect(isThreadExplicit()).toBe(true);
|
||||
});
|
||||
|
||||
test("a controlled config thread wins over the [threadId] input (input ignored)", () => {
|
||||
const { fixture, config, agentThreadId } = setupAgentPrecedence({
|
||||
configAgentId: "cfg-agent",
|
||||
configThreadId: "cfg-thread",
|
||||
componentThreadId: "t1",
|
||||
});
|
||||
|
||||
TestBed.flushEffects();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(config.threadId()).toBe("cfg-thread");
|
||||
expect(agentThreadId()).toBe("cfg-thread");
|
||||
});
|
||||
@@ -0,0 +1,948 @@
|
||||
import { Component } from "@angular/core";
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { signal } from "@angular/core";
|
||||
import { test, expect, vi, beforeEach } from "vitest";
|
||||
import type { DrawerThread } from "@copilotkit/web-components/threads-drawer";
|
||||
import type { RuntimeLicenseStatus } from "@copilotkit/core";
|
||||
import {
|
||||
CopilotThreadsDrawer,
|
||||
CopilotThreadsDrawerRow,
|
||||
} from "../copilot-threads-drawer";
|
||||
import type { Thread } from "../../../threads";
|
||||
import { COPILOT_CHAT_CONFIGURATION } from "../../../chat-configuration";
|
||||
import { CopilotKit } from "../../../copilotkit";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock the `CopilotKit` service so the component's `inject(CopilotKit)` resolves
|
||||
// to a minimal fake exposing only the `licenseStatus` signal the drawer reads.
|
||||
// A module-level signal lets tests drive the license gate. Defaults to "valid"
|
||||
// (fully licensed) so non-license tests render the list path unchanged.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const licenseStatusSignal = signal<RuntimeLicenseStatus | undefined>("valid");
|
||||
const fakeCopilotKit = { licenseStatus: licenseStatusSignal };
|
||||
const copilotkitProvider = { provide: CopilotKit, useValue: fakeCopilotKit };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock `injectThreads` so the component never tries to connect to a real
|
||||
// CopilotKit runtime during tests.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Writable signals that back the mock threads store. */
|
||||
const threadsState = {
|
||||
threads: signal<Thread[]>([]),
|
||||
isLoading: signal(false),
|
||||
error: signal<Error | null>(null),
|
||||
listError: signal<Error | null>(null),
|
||||
fetchMoreError: signal<Error | null>(null),
|
||||
hasMoreThreads: signal(false),
|
||||
isFetchingMoreThreads: signal(false),
|
||||
isMutating: signal(false),
|
||||
fetchMoreThreads: vi.fn(),
|
||||
refetchThreads: vi.fn(),
|
||||
startNewThread: vi.fn(),
|
||||
renameThread: vi.fn(),
|
||||
archiveThread: vi.fn().mockResolvedValue(undefined),
|
||||
unarchiveThread: vi.fn().mockResolvedValue(undefined),
|
||||
deleteThread: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
vi.mock("../../../threads", () => ({ injectThreads: () => threadsState }));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Host components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Host component that renders {@link CopilotThreadsDrawer} under test. */
|
||||
@Component({
|
||||
selector: "test-host",
|
||||
standalone: true,
|
||||
imports: [CopilotThreadsDrawer],
|
||||
template: `
|
||||
<copilot-threads-drawer />
|
||||
`,
|
||||
})
|
||||
class HostComponent {}
|
||||
|
||||
/** Host component that binds an override callback for thread-select. */
|
||||
@Component({
|
||||
selector: "test-host-thread-select",
|
||||
standalone: true,
|
||||
imports: [CopilotThreadsDrawer],
|
||||
template: `
|
||||
<copilot-threads-drawer [onThreadSelect]="spy" />
|
||||
`,
|
||||
})
|
||||
class HostWithThreadSelectComponent {
|
||||
spy = vi.fn();
|
||||
}
|
||||
|
||||
/** Host component that binds an override callback for new-thread. */
|
||||
@Component({
|
||||
selector: "test-host-new-thread",
|
||||
standalone: true,
|
||||
imports: [CopilotThreadsDrawer],
|
||||
template: `
|
||||
<copilot-threads-drawer [onNewThread]="spy" />
|
||||
`,
|
||||
})
|
||||
class HostWithNewThreadComponent {
|
||||
spy = vi.fn();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Setup helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Configures TestBed, creates the host fixture, runs change detection, and
|
||||
* returns the fixture together with the rendered `<copilotkit-threads-drawer>` element.
|
||||
*/
|
||||
function setup() {
|
||||
licenseStatusSignal.set("valid");
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HostComponent],
|
||||
providers: [copilotkitProvider],
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(HostComponent);
|
||||
fixture.detectChanges();
|
||||
const el = (fixture.nativeElement as HTMLElement).querySelector(
|
||||
"copilotkit-threads-drawer",
|
||||
) as
|
||||
| (HTMLElement & {
|
||||
threads: DrawerThread[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
hasMore: boolean;
|
||||
fetchingMore: boolean;
|
||||
activeThreadId: string | null;
|
||||
})
|
||||
| null;
|
||||
return { fixture, el };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a minimal fake {@link CopilotChatConfiguration}-shaped object for
|
||||
* testing event routing without a real service instance.
|
||||
*/
|
||||
function fakeConfig() {
|
||||
return {
|
||||
threadId: signal("active-1"),
|
||||
agentId: signal<string | undefined>(undefined),
|
||||
hasExplicitThreadId: signal(true),
|
||||
setActiveThreadId: vi.fn(),
|
||||
startNewThread: vi.fn(),
|
||||
// Drawer open-state coordination consumed by the wrapper.
|
||||
drawerOpen: signal(false),
|
||||
setDrawerOpen: vi.fn(),
|
||||
registerDrawer: vi.fn(() => vi.fn()),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures TestBed with the fake config provided as the
|
||||
* `COPILOT_CHAT_CONFIGURATION` token value, creates the fixture, and returns
|
||||
* the fixture, the inner `<copilotkit-threads-drawer>` element, and the config spy.
|
||||
*/
|
||||
function setupWithConfig(config = fakeConfig()) {
|
||||
licenseStatusSignal.set("valid");
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HostComponent],
|
||||
providers: [
|
||||
{ provide: COPILOT_CHAT_CONFIGURATION, useValue: config },
|
||||
copilotkitProvider,
|
||||
],
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(HostComponent);
|
||||
fixture.detectChanges();
|
||||
const el = (fixture.nativeElement as HTMLElement).querySelector(
|
||||
"copilotkit-threads-drawer",
|
||||
) as HTMLElement | null;
|
||||
return { fixture, el: el!, config };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The `threadsState` mock is module-level, so reset every signal to its default
|
||||
// and clear the mock fns' call history before each test. Without this, a test
|
||||
// that flips a signal (e.g. isLoading/hasMoreThreads/error) leaks that state
|
||||
// into whichever test runs next — the same order-coupling guard the
|
||||
// react-core/vue suites establish with their own beforeEach.
|
||||
beforeEach(() => {
|
||||
threadsState.threads.set([]);
|
||||
threadsState.isLoading.set(false);
|
||||
threadsState.error.set(null);
|
||||
threadsState.listError.set(null);
|
||||
threadsState.fetchMoreError.set(null);
|
||||
threadsState.hasMoreThreads.set(false);
|
||||
threadsState.isFetchingMoreThreads.set(false);
|
||||
threadsState.isMutating.set(false);
|
||||
threadsState.fetchMoreThreads.mockClear();
|
||||
threadsState.refetchThreads.mockClear();
|
||||
threadsState.startNewThread.mockClear();
|
||||
threadsState.renameThread.mockClear();
|
||||
threadsState.archiveThread.mockClear();
|
||||
threadsState.unarchiveThread.mockClear();
|
||||
threadsState.deleteThread.mockClear();
|
||||
});
|
||||
|
||||
test("renders <copilotkit-threads-drawer> with the default data-testid", () => {
|
||||
const { el } = setup();
|
||||
|
||||
expect(el).not.toBeNull();
|
||||
expect(customElements.get("copilotkit-threads-drawer")).toBeDefined();
|
||||
expect(el!.getAttribute("data-testid")).toBe("copilot-threads-drawer");
|
||||
});
|
||||
|
||||
test("binds threads list and load state onto the drawer element imperatively", async () => {
|
||||
// Reset mock state to known initial values before this test.
|
||||
threadsState.threads.set([]);
|
||||
threadsState.isLoading.set(false);
|
||||
threadsState.error.set(null);
|
||||
threadsState.listError.set(null);
|
||||
threadsState.hasMoreThreads.set(false);
|
||||
threadsState.isFetchingMoreThreads.set(false);
|
||||
|
||||
const { fixture, el } = setup();
|
||||
|
||||
// Arrange: set mock signals with a thread row, loading, error, hasMore.
|
||||
// Set both error and listError to a genuine fetch error so the element
|
||||
// receives the message (the component binds listError, not error).
|
||||
const thread: Thread = {
|
||||
id: "t1",
|
||||
agentId: "default",
|
||||
name: "My Thread",
|
||||
archived: false,
|
||||
createdAt: "2024-01-01T00:00:00Z",
|
||||
updatedAt: "2024-01-02T00:00:00Z",
|
||||
lastRunAt: "2024-01-02T12:00:00Z",
|
||||
};
|
||||
const loadError = new Error("load failed");
|
||||
threadsState.threads.set([thread]);
|
||||
threadsState.isLoading.set(true);
|
||||
threadsState.error.set(loadError);
|
||||
threadsState.listError.set(loadError);
|
||||
threadsState.hasMoreThreads.set(true);
|
||||
|
||||
// Act: trigger change detection so the effect re-runs.
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
// Assert: element JS properties reflect the signals.
|
||||
const expected: DrawerThread[] = [
|
||||
{
|
||||
id: "t1",
|
||||
name: "My Thread",
|
||||
archived: false,
|
||||
createdAt: "2024-01-01T00:00:00Z",
|
||||
updatedAt: "2024-01-02T00:00:00Z",
|
||||
lastRunAt: "2024-01-02T12:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
expect(el!.threads).toEqual(expected);
|
||||
expect(el!.loading).toBe(true);
|
||||
expect(el!.error).toBe("load failed");
|
||||
expect(el!.hasMore).toBe(true);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event routing tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("thread-selected routes to setActiveThreadId with explicit:true", () => {
|
||||
threadsState.startNewThread.mockClear();
|
||||
const { el, config } = setupWithConfig();
|
||||
|
||||
el.dispatchEvent(
|
||||
new CustomEvent("thread-selected", {
|
||||
detail: { threadId: "t9" },
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(config.setActiveThreadId).toHaveBeenCalledWith("t9", {
|
||||
explicit: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("new-thread routes to both threadsState.startNewThread and config.startNewThread", () => {
|
||||
threadsState.startNewThread.mockClear();
|
||||
const { el, config } = setupWithConfig();
|
||||
|
||||
el.dispatchEvent(new CustomEvent("new-thread", { bubbles: true }));
|
||||
|
||||
expect(threadsState.startNewThread).toHaveBeenCalled();
|
||||
expect(config.startNewThread).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("archive routes to threadsState.archiveThread with the thread id", () => {
|
||||
threadsState.archiveThread.mockClear();
|
||||
const { el } = setupWithConfig();
|
||||
|
||||
el.dispatchEvent(
|
||||
new CustomEvent("archive", {
|
||||
detail: { threadId: "t-arc" },
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(threadsState.archiveThread).toHaveBeenCalledWith("t-arc");
|
||||
});
|
||||
|
||||
test("unarchive routes to threadsState.unarchiveThread with the thread id", () => {
|
||||
threadsState.unarchiveThread.mockClear();
|
||||
const { el } = setupWithConfig();
|
||||
|
||||
el.dispatchEvent(
|
||||
new CustomEvent("unarchive", {
|
||||
detail: { threadId: "t-unarc" },
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(threadsState.unarchiveThread).toHaveBeenCalledWith("t-unarc");
|
||||
});
|
||||
|
||||
test("filter-change routes to threadsState.refetchThreads", () => {
|
||||
threadsState.refetchThreads.mockClear();
|
||||
const { el } = setupWithConfig();
|
||||
|
||||
el.dispatchEvent(new CustomEvent("filter-change", { bubbles: true }));
|
||||
|
||||
expect(threadsState.refetchThreads).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("retry with scope fetch-more routes to threadsState.fetchMoreThreads", () => {
|
||||
threadsState.fetchMoreThreads.mockClear();
|
||||
const { el } = setupWithConfig();
|
||||
|
||||
el.dispatchEvent(
|
||||
new CustomEvent("retry", {
|
||||
detail: { scope: "fetch-more" },
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(threadsState.fetchMoreThreads).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("load-more routes to threadsState.fetchMoreThreads", () => {
|
||||
threadsState.fetchMoreThreads.mockClear();
|
||||
const { el } = setupWithConfig();
|
||||
|
||||
el.dispatchEvent(new CustomEvent("load-more", { bubbles: true }));
|
||||
|
||||
expect(threadsState.fetchMoreThreads).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("retry with scope initial routes to threadsState.refetchThreads", () => {
|
||||
threadsState.refetchThreads.mockClear();
|
||||
const { el } = setupWithConfig();
|
||||
|
||||
el.dispatchEvent(
|
||||
new CustomEvent("retry", {
|
||||
detail: { scope: "initial" },
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(threadsState.refetchThreads).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("delete of active thread triggers startNewThread on both threads and config after delete resolves", async () => {
|
||||
threadsState.deleteThread.mockClear();
|
||||
threadsState.startNewThread.mockClear();
|
||||
// The config's threadId signal is set to "active-1" by fakeConfig().
|
||||
const { el, config } = setupWithConfig();
|
||||
|
||||
el.dispatchEvent(
|
||||
new CustomEvent("delete", {
|
||||
detail: { threadId: "active-1" },
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// Wait for the deleteThread promise to resolve.
|
||||
await Promise.resolve();
|
||||
|
||||
expect(threadsState.deleteThread).toHaveBeenCalledWith("active-1");
|
||||
expect(threadsState.startNewThread).toHaveBeenCalled();
|
||||
expect(config.startNewThread).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Host escape-hatch callback tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("onThreadSelect override is called with threadId and config.setActiveThreadId is NOT called", () => {
|
||||
threadsState.startNewThread.mockClear();
|
||||
const config = fakeConfig();
|
||||
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HostWithThreadSelectComponent],
|
||||
providers: [
|
||||
{ provide: COPILOT_CHAT_CONFIGURATION, useValue: config },
|
||||
copilotkitProvider,
|
||||
],
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(HostWithThreadSelectComponent);
|
||||
fixture.detectChanges();
|
||||
const el = (fixture.nativeElement as HTMLElement).querySelector(
|
||||
"copilotkit-threads-drawer",
|
||||
) as HTMLElement;
|
||||
|
||||
el.dispatchEvent(
|
||||
new CustomEvent("thread-selected", {
|
||||
detail: { threadId: "t5" },
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(fixture.componentInstance.spy).toHaveBeenCalledWith("t5");
|
||||
expect(config.setActiveThreadId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("onNewThread override is called and threads.startNewThread is always called; config.startNewThread is NOT called", () => {
|
||||
threadsState.startNewThread.mockClear();
|
||||
const config = fakeConfig();
|
||||
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HostWithNewThreadComponent],
|
||||
providers: [
|
||||
{ provide: COPILOT_CHAT_CONFIGURATION, useValue: config },
|
||||
copilotkitProvider,
|
||||
],
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(HostWithNewThreadComponent);
|
||||
fixture.detectChanges();
|
||||
const el = (fixture.nativeElement as HTMLElement).querySelector(
|
||||
"copilotkit-threads-drawer",
|
||||
) as HTMLElement;
|
||||
|
||||
el.dispatchEvent(new CustomEvent("new-thread", { bubbles: true }));
|
||||
|
||||
expect(threadsState.startNewThread).toHaveBeenCalled();
|
||||
expect(fixture.componentInstance.spy).toHaveBeenCalled();
|
||||
expect(config.startNewThread).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// listError filter tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("dev/config error on error() does not leak to element.error when listError() is null", async () => {
|
||||
threadsState.error.set(new Error("Runtime URL is not configured"));
|
||||
threadsState.listError.set(null);
|
||||
|
||||
const { fixture, el } = setup();
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(el!.error).toBeNull();
|
||||
});
|
||||
|
||||
test("genuine fetch error surfaces on element.error when listError() is non-null", async () => {
|
||||
const fetchErr = new Error("list fetch failed");
|
||||
threadsState.error.set(fetchErr);
|
||||
threadsState.listError.set(fetchErr);
|
||||
|
||||
const { fixture, el } = setup();
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(el!.error).toBe("list fetch failed");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// D2: fetchMoreError forwarding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("fetchMoreError is forwarded to the element's fetchMoreError property; error stays null", async () => {
|
||||
threadsState.error.set(null);
|
||||
threadsState.listError.set(null);
|
||||
threadsState.fetchMoreError.set(new Error("couldn't load more"));
|
||||
|
||||
const { fixture, el } = setup();
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(
|
||||
(el as unknown as { fetchMoreError: string | null }).fetchMoreError,
|
||||
).toBe("couldn't load more");
|
||||
// The dedicated fetch-more channel must NOT bleed into the initial-list error.
|
||||
expect(el!.error).toBeNull();
|
||||
|
||||
// Clearing the fetch-more error clears the element property.
|
||||
threadsState.fetchMoreError.set(null);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
expect(
|
||||
(el as unknown as { fetchMoreError: string | null }).fetchMoreError,
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// D1: open-state coordination
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("drawer starts CLOSED (el.open === false) with a provider present", async () => {
|
||||
const { fixture, el } = setupWithConfig();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect((el as unknown as { open: boolean }).open).toBe(false);
|
||||
});
|
||||
|
||||
test("drawer starts CLOSED (el.open === false) with no provider (local fallback)", async () => {
|
||||
const { fixture, el } = setup();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect((el as unknown as { open: boolean }).open).toBe(false);
|
||||
});
|
||||
|
||||
test("open-change routes to config.setDrawerOpen when a provider is present", () => {
|
||||
const { el, config } = setupWithConfig();
|
||||
|
||||
el.dispatchEvent(
|
||||
new CustomEvent("open-change", {
|
||||
detail: { open: true },
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(config.setDrawerOpen).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
test("open-change drives the local fallback (el.open flips) when no provider is present", async () => {
|
||||
const { fixture, el } = setup();
|
||||
await fixture.whenStable();
|
||||
expect((el as unknown as { open: boolean }).open).toBe(false);
|
||||
|
||||
el!.dispatchEvent(
|
||||
new CustomEvent("open-change", {
|
||||
detail: { open: true },
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect((el as unknown as { open: boolean }).open).toBe(true);
|
||||
});
|
||||
|
||||
test("registerDrawer is called once when a provider is present", () => {
|
||||
const { config } = setupWithConfig();
|
||||
expect(config.registerDrawer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// D3: focus-return to the chat input on thread select
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("thread-selected returns focus to the chat input (document-global fallback)", () => {
|
||||
const { el } = setupWithConfig();
|
||||
|
||||
// No `copilot-chat-view` ancestor in the test DOM, so findChatInput uses the
|
||||
// document-global fallback: a `<textarea copilotChatTextarea>` on the page.
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.setAttribute("copilotChatTextarea", "");
|
||||
document.body.appendChild(textarea);
|
||||
const focusSpy = vi.spyOn(textarea, "focus");
|
||||
|
||||
try {
|
||||
el.dispatchEvent(
|
||||
new CustomEvent("thread-selected", {
|
||||
detail: { threadId: "t-focus" },
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(focusSpy).toHaveBeenCalled();
|
||||
} finally {
|
||||
focusSpy.mockRestore();
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
});
|
||||
|
||||
test("thread-selected focuses the chat input scoped to the ancestor copilot-chat-view", () => {
|
||||
const { el } = setupWithConfig();
|
||||
|
||||
// Wrap the drawer in a `copilot-chat-view` that owns its own input, and add a
|
||||
// decoy input elsewhere in the document (earlier in document order, so the
|
||||
// global fallback would pick IT). findChatInput must prefer the scoped one.
|
||||
const decoy = document.createElement("textarea");
|
||||
decoy.setAttribute("copilotChatTextarea", "");
|
||||
document.body.appendChild(decoy);
|
||||
|
||||
const chatView = document.createElement("copilot-chat-view");
|
||||
const scoped = document.createElement("textarea");
|
||||
scoped.setAttribute("copilotChatTextarea", "");
|
||||
const parent = el.parentElement as HTMLElement;
|
||||
parent.insertBefore(chatView, el);
|
||||
chatView.appendChild(scoped);
|
||||
chatView.appendChild(el); // drawer now lives inside the chat-view
|
||||
|
||||
const scopedSpy = vi.spyOn(scoped, "focus");
|
||||
const decoySpy = vi.spyOn(decoy, "focus");
|
||||
try {
|
||||
el.dispatchEvent(
|
||||
new CustomEvent("thread-selected", {
|
||||
detail: { threadId: "t-scoped" },
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(scopedSpy).toHaveBeenCalled();
|
||||
expect(decoySpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
scopedSpy.mockRestore();
|
||||
decoySpy.mockRestore();
|
||||
decoy.remove();
|
||||
chatView.remove();
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-row custom content projection (copilotThreadsDrawerRow directive)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Host component that projects a copilotThreadsDrawerRow template into the drawer. */
|
||||
@Component({
|
||||
selector: "test-host-row",
|
||||
standalone: true,
|
||||
imports: [CopilotThreadsDrawer, CopilotThreadsDrawerRow],
|
||||
template: `
|
||||
<copilot-threads-drawer
|
||||
><ng-template copilotThreadsDrawerRow let-t
|
||||
>ROW:{{ t.name }}</ng-template
|
||||
></copilot-threads-drawer
|
||||
>
|
||||
`,
|
||||
})
|
||||
class HostWithRowComponent {}
|
||||
|
||||
/** Host component that projects a slotted light-DOM child into the drawer. */
|
||||
@Component({
|
||||
selector: "test-host-slot",
|
||||
standalone: true,
|
||||
imports: [CopilotThreadsDrawer],
|
||||
template: `
|
||||
<copilot-threads-drawer
|
||||
><span slot="launcher-icon">ICON-X</span></copilot-threads-drawer
|
||||
>
|
||||
`,
|
||||
})
|
||||
class HostWithSlotComponent {}
|
||||
|
||||
test("slotted light-DOM children pass through to <copilotkit-threads-drawer>", () => {
|
||||
licenseStatusSignal.set("valid");
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HostWithSlotComponent],
|
||||
providers: [copilotkitProvider],
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(HostWithSlotComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
const drawerEl = (fixture.nativeElement as HTMLElement).querySelector(
|
||||
"copilotkit-threads-drawer",
|
||||
);
|
||||
const slotChild = drawerEl?.querySelector('[slot="launcher-icon"]');
|
||||
expect(slotChild).not.toBeNull();
|
||||
expect(slotChild?.textContent).toContain("ICON-X");
|
||||
});
|
||||
|
||||
test("copilotThreadsDrawerRow renders per-row slot content for each thread", () => {
|
||||
licenseStatusSignal.set("valid");
|
||||
threadsState.threads.set([
|
||||
{
|
||||
id: "t1",
|
||||
name: "Alpha",
|
||||
archived: false,
|
||||
createdAt: "x",
|
||||
updatedAt: "x",
|
||||
agentId: "default",
|
||||
},
|
||||
]);
|
||||
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HostWithRowComponent],
|
||||
providers: [copilotkitProvider],
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(HostWithRowComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
const slotEl = (fixture.nativeElement as HTMLElement).querySelector(
|
||||
'[slot="row:t1"]',
|
||||
);
|
||||
expect(slotEl?.textContent?.trim()).toContain("ROW:Alpha");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// label input forwarding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Host that passes a custom label to the drawer. */
|
||||
@Component({
|
||||
selector: "test-host-label",
|
||||
standalone: true,
|
||||
imports: [CopilotThreadsDrawer],
|
||||
template: `
|
||||
<copilot-threads-drawer [label]="'Custom'" />
|
||||
`,
|
||||
})
|
||||
class HostWithLabelComponent {}
|
||||
|
||||
test("label input is forwarded to the <copilotkit-threads-drawer> element label property", () => {
|
||||
licenseStatusSignal.set("valid");
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HostWithLabelComponent],
|
||||
providers: [copilotkitProvider],
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(HostWithLabelComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
const drawerEl = (fixture.nativeElement as HTMLElement).querySelector(
|
||||
"copilotkit-threads-drawer",
|
||||
) as HTMLElement & { label: string };
|
||||
|
||||
expect(drawerEl.label).toBe("Custom");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// recentLabel input forwarding (ENT-1051 UX redesign parity)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Host that passes a custom recentLabel to the drawer. */
|
||||
@Component({
|
||||
selector: "test-host-recent-label",
|
||||
standalone: true,
|
||||
imports: [CopilotThreadsDrawer],
|
||||
template: `
|
||||
<copilot-threads-drawer [recentLabel]="'History'" />
|
||||
`,
|
||||
})
|
||||
class HostWithRecentLabelComponent {}
|
||||
|
||||
test("recentLabel input is forwarded to the element's recent-label attribute", () => {
|
||||
licenseStatusSignal.set("valid");
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HostWithRecentLabelComponent],
|
||||
providers: [copilotkitProvider],
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(HostWithRecentLabelComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
const el = (fixture.nativeElement as HTMLElement).querySelector(
|
||||
"copilotkit-threads-drawer",
|
||||
) as HTMLElement;
|
||||
|
||||
expect(el.getAttribute("recent-label")).toBe("History");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// collapsible input forwarding + collapseChange output (ENT-1051)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Host that passes collapsible={false} to the drawer. */
|
||||
@Component({
|
||||
selector: "test-host-collapsible",
|
||||
standalone: true,
|
||||
imports: [CopilotThreadsDrawer],
|
||||
template: `
|
||||
<copilot-threads-drawer [collapsible]="false" />
|
||||
`,
|
||||
})
|
||||
class HostWithCollapsibleComponent {}
|
||||
|
||||
test("collapsible input is forwarded to the element's collapsible property", () => {
|
||||
licenseStatusSignal.set("valid");
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HostWithCollapsibleComponent],
|
||||
providers: [copilotkitProvider],
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(HostWithCollapsibleComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
const el = (fixture.nativeElement as HTMLElement).querySelector(
|
||||
"copilotkit-threads-drawer",
|
||||
) as HTMLElement & { collapsible: boolean };
|
||||
|
||||
expect(el.collapsible).toBe(false);
|
||||
});
|
||||
|
||||
/** Host that binds the `collapseChange` output to a spy. */
|
||||
@Component({
|
||||
selector: "test-host-collapse-change",
|
||||
standalone: true,
|
||||
imports: [CopilotThreadsDrawer],
|
||||
template: `
|
||||
<copilot-threads-drawer (collapseChange)="spy($event)" />
|
||||
`,
|
||||
})
|
||||
class HostWithCollapseChangeComponent {
|
||||
spy = vi.fn();
|
||||
}
|
||||
|
||||
test("collapseChange output emits the collapsed state when the element fires a `collapse-change` event", () => {
|
||||
licenseStatusSignal.set("valid");
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HostWithCollapseChangeComponent],
|
||||
providers: [copilotkitProvider],
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(HostWithCollapseChangeComponent);
|
||||
fixture.detectChanges();
|
||||
const el = (fixture.nativeElement as HTMLElement).querySelector(
|
||||
"copilotkit-threads-drawer",
|
||||
) as HTMLElement;
|
||||
|
||||
el.dispatchEvent(
|
||||
new CustomEvent("collapse-change", {
|
||||
detail: { collapsed: true },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(fixture.componentInstance.spy).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// License gate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Casts the drawer element to expose the license-related JS properties. */
|
||||
type LicensedEl = HTMLElement & {
|
||||
licensed: boolean;
|
||||
loading: boolean;
|
||||
licenseUrl: string;
|
||||
};
|
||||
|
||||
test("a resolved-unlicensed status gates the element to the locked view", async () => {
|
||||
const { fixture, el } = setup();
|
||||
|
||||
licenseStatusSignal.set("none");
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect((el as LicensedEl).licensed).toBe(false);
|
||||
});
|
||||
|
||||
test("an expired status gates the element to the locked view", async () => {
|
||||
const { fixture, el } = setup();
|
||||
|
||||
licenseStatusSignal.set("expired");
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect((el as LicensedEl).licensed).toBe(false);
|
||||
});
|
||||
|
||||
test("a valid license keeps the element in the licensed (list) state", async () => {
|
||||
const { fixture, el } = setup();
|
||||
|
||||
licenseStatusSignal.set("valid");
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect((el as LicensedEl).licensed).toBe(true);
|
||||
});
|
||||
|
||||
test("a pending (unresolved) status renders licensed-and-loading, never the locked view", async () => {
|
||||
threadsState.isLoading.set(false);
|
||||
const { fixture, el } = setup();
|
||||
|
||||
licenseStatusSignal.set(undefined);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect((el as LicensedEl).licensed).toBe(true);
|
||||
expect((el as LicensedEl).loading).toBe(true);
|
||||
});
|
||||
|
||||
/** Host that passes a custom licenseUrl to the drawer. */
|
||||
@Component({
|
||||
selector: "test-host-license-url",
|
||||
standalone: true,
|
||||
imports: [CopilotThreadsDrawer],
|
||||
template: `
|
||||
<copilot-threads-drawer [licenseUrl]="'https://example.com/upgrade'" />
|
||||
`,
|
||||
})
|
||||
class HostWithLicenseUrlComponent {}
|
||||
|
||||
test("licenseUrl input is forwarded to the element's licenseUrl property", () => {
|
||||
licenseStatusSignal.set("valid");
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HostWithLicenseUrlComponent],
|
||||
providers: [copilotkitProvider],
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(HostWithLicenseUrlComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
const drawerEl = (fixture.nativeElement as HTMLElement).querySelector(
|
||||
"copilotkit-threads-drawer",
|
||||
) as LicensedEl;
|
||||
|
||||
expect(drawerEl.licenseUrl).toBe("https://example.com/upgrade");
|
||||
});
|
||||
|
||||
/** Host that binds an onLicensed override callback. */
|
||||
@Component({
|
||||
selector: "test-host-licensed",
|
||||
standalone: true,
|
||||
imports: [CopilotThreadsDrawer],
|
||||
template: `
|
||||
<copilot-threads-drawer [onLicensed]="spy" />
|
||||
`,
|
||||
})
|
||||
class HostWithLicensedComponent {
|
||||
spy = vi.fn();
|
||||
}
|
||||
|
||||
test("onLicensed handler is invoked when the element emits the `licensed` event", () => {
|
||||
licenseStatusSignal.set("valid");
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HostWithLicensedComponent],
|
||||
providers: [copilotkitProvider],
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(HostWithLicensedComponent);
|
||||
fixture.detectChanges();
|
||||
const el = (fixture.nativeElement as HTMLElement).querySelector(
|
||||
"copilotkit-threads-drawer",
|
||||
) as HTMLElement;
|
||||
|
||||
el.dispatchEvent(
|
||||
new CustomEvent("licensed", {
|
||||
detail: { licenseUrl: "https://example.com" },
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(fixture.componentInstance.spy).toHaveBeenCalled();
|
||||
});
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import { Component } from "@angular/core";
|
||||
|
||||
@Component({
|
||||
template: "",
|
||||
})
|
||||
export class DummyActivityRenderer {}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
import { Component } from "@angular/core";
|
||||
|
||||
@Component({
|
||||
template: "",
|
||||
})
|
||||
export class FallbackActivityRenderer {}
|
||||
@@ -0,0 +1,262 @@
|
||||
import {
|
||||
Component,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
computed,
|
||||
ChangeDetectionStrategy,
|
||||
ViewEncapsulation,
|
||||
} from "@angular/core";
|
||||
|
||||
import {
|
||||
LucideAngularModule,
|
||||
Copy,
|
||||
Check,
|
||||
ThumbsUp,
|
||||
ThumbsDown,
|
||||
Volume2,
|
||||
RefreshCw,
|
||||
} from "lucide-angular";
|
||||
import { CopilotTooltip } from "../../directives/tooltip";
|
||||
import { cn } from "../../utils";
|
||||
import { injectChatLabels } from "../../chat-config";
|
||||
import { copyToClipboard } from "@copilotkit/shared";
|
||||
|
||||
// Base toolbar button component
|
||||
@Component({
|
||||
selector: "button[copilotChatAssistantMessageToolbarButton]",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
template: `
|
||||
<ng-content></ng-content>
|
||||
`,
|
||||
host: {
|
||||
"[class]": "computedClass()",
|
||||
"[attr.disabled]": "disabled() ? true : null",
|
||||
type: "button",
|
||||
"[attr.aria-label]": "title()",
|
||||
},
|
||||
hostDirectives: [
|
||||
{
|
||||
directive: CopilotTooltip,
|
||||
inputs: ["copilotTooltip: title", "tooltipPosition", "tooltipDelay"],
|
||||
},
|
||||
],
|
||||
})
|
||||
export class CopilotChatAssistantMessageToolbarButton {
|
||||
title = input<string>("");
|
||||
disabled = input<boolean>(false);
|
||||
inputClass = input<string | undefined>();
|
||||
|
||||
computedClass = computed(() => {
|
||||
return cn(
|
||||
// Flex centering with gap (from React button base styles)
|
||||
"cpk:inline-flex cpk:items-center cpk:justify-center cpk:gap-2",
|
||||
// Cursor
|
||||
"cpk:cursor-pointer",
|
||||
// Background and text
|
||||
"cpk:p-0 cpk:text-[rgb(93,93,93)] cpk:hover:bg-[#E8E8E8]",
|
||||
// Dark mode
|
||||
"cpk:dark:text-[rgb(243,243,243)] cpk:dark:hover:bg-[#303030]",
|
||||
// Shape and sizing
|
||||
"cpk:h-8 cpk:w-8 cpk:rounded-md",
|
||||
// Interactions
|
||||
"cpk:transition-colors",
|
||||
// Hover states
|
||||
"cpk:hover:text-[rgb(93,93,93)]",
|
||||
"cpk:dark:hover:text-[rgb(243,243,243)]",
|
||||
// Focus states
|
||||
"cpk:focus:outline-none cpk:focus:ring-2 cpk:focus:ring-offset-2",
|
||||
// Disabled state
|
||||
"cpk:disabled:opacity-50 cpk:disabled:cursor-not-allowed",
|
||||
// SVG styling from React Button component
|
||||
"cpk:[&_svg]:pointer-events-none cpk:[&_svg]:shrink-0",
|
||||
// Ensure proper sizing
|
||||
"cpk:shrink-0",
|
||||
this.inputClass(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Copy button component
|
||||
@Component({
|
||||
selector: "copilot-chat-assistant-message-copy-button",
|
||||
imports: [LucideAngularModule, CopilotChatAssistantMessageToolbarButton],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
template: `
|
||||
<button
|
||||
copilotChatAssistantMessageToolbarButton
|
||||
[title]="title() || labels.assistantMessageToolbarCopyMessageLabel"
|
||||
[disabled]="disabled()"
|
||||
[inputClass]="inputClass()"
|
||||
(click)="handleCopy($event)"
|
||||
>
|
||||
@if (copied()) {
|
||||
<lucide-angular [img]="CheckIcon" [size]="18"></lucide-angular>
|
||||
} @else {
|
||||
<lucide-angular [img]="CopyIcon" [size]="18"></lucide-angular>
|
||||
}
|
||||
</button>
|
||||
`,
|
||||
})
|
||||
export class CopilotChatAssistantMessageCopyButton {
|
||||
readonly title = input<string | undefined>();
|
||||
readonly disabled = input<boolean>(false);
|
||||
readonly inputClass = input<string | undefined>();
|
||||
readonly content = input<string | undefined>();
|
||||
readonly clicked = output<void>();
|
||||
readonly CopyIcon = Copy;
|
||||
readonly CheckIcon = Check;
|
||||
readonly copied = signal(false);
|
||||
readonly labels = injectChatLabels();
|
||||
|
||||
handleCopy(event?: Event): void {
|
||||
event?.stopPropagation();
|
||||
if (!this.content()) return;
|
||||
|
||||
copyToClipboard(this.content()!).then((success) => {
|
||||
if (success) {
|
||||
this.copied.set(true);
|
||||
this.clicked.emit();
|
||||
setTimeout(() => this.copied.set(false), 2000);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Thumbs up button component
|
||||
@Component({
|
||||
selector: "copilot-chat-assistant-message-thumbs-up-button",
|
||||
imports: [LucideAngularModule, CopilotChatAssistantMessageToolbarButton],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
template: `
|
||||
<button
|
||||
copilotChatAssistantMessageToolbarButton
|
||||
[title]="title() || labels.assistantMessageToolbarThumbsUpLabel"
|
||||
[disabled]="disabled()"
|
||||
[inputClass]="inputClass()"
|
||||
(click)="handleClick($event)"
|
||||
>
|
||||
<lucide-angular [img]="ThumbsUpIcon" [size]="18"></lucide-angular>
|
||||
</button>
|
||||
`,
|
||||
})
|
||||
export class CopilotChatAssistantMessageThumbsUpButton {
|
||||
readonly title = input<string | undefined>();
|
||||
readonly disabled = input<boolean>(false);
|
||||
readonly inputClass = input<string | undefined>();
|
||||
readonly clicked = output<void>();
|
||||
readonly ThumbsUpIcon = ThumbsUp;
|
||||
readonly labels = injectChatLabels();
|
||||
|
||||
handleClick(event?: Event): void {
|
||||
event?.stopPropagation();
|
||||
if (!this.disabled()) {
|
||||
this.clicked.emit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Thumbs down button component
|
||||
@Component({
|
||||
selector: "copilot-chat-assistant-message-thumbs-down-button",
|
||||
imports: [LucideAngularModule, CopilotChatAssistantMessageToolbarButton],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
template: `
|
||||
<button
|
||||
copilotChatAssistantMessageToolbarButton
|
||||
[title]="title() || labels.assistantMessageToolbarThumbsDownLabel"
|
||||
[disabled]="disabled()"
|
||||
[inputClass]="inputClass()"
|
||||
(click)="handleClick($event)"
|
||||
>
|
||||
<lucide-angular [img]="ThumbsDownIcon" [size]="18"></lucide-angular>
|
||||
</button>
|
||||
`,
|
||||
})
|
||||
export class CopilotChatAssistantMessageThumbsDownButton {
|
||||
readonly title = input<string | undefined>();
|
||||
readonly disabled = input<boolean>(false);
|
||||
readonly inputClass = input<string | undefined>();
|
||||
readonly clicked = output<void>();
|
||||
readonly ThumbsDownIcon = ThumbsDown;
|
||||
readonly labels = injectChatLabels();
|
||||
|
||||
handleClick(event?: Event): void {
|
||||
event?.stopPropagation();
|
||||
if (!this.disabled()) {
|
||||
this.clicked.emit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read aloud button component
|
||||
@Component({
|
||||
selector: "copilot-chat-assistant-message-read-aloud-button",
|
||||
imports: [LucideAngularModule, CopilotChatAssistantMessageToolbarButton],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
template: `
|
||||
<button
|
||||
copilotChatAssistantMessageToolbarButton
|
||||
[title]="title() || labels.assistantMessageToolbarReadAloudLabel"
|
||||
[disabled]="disabled()"
|
||||
[inputClass]="inputClass()"
|
||||
(click)="handleClick($event)"
|
||||
>
|
||||
<lucide-angular [img]="Volume2Icon" [size]="20"></lucide-angular>
|
||||
</button>
|
||||
`,
|
||||
})
|
||||
export class CopilotChatAssistantMessageReadAloudButton {
|
||||
readonly title = input<string | undefined>();
|
||||
readonly disabled = input<boolean>(false);
|
||||
readonly inputClass = input<string | undefined>();
|
||||
readonly clicked = output<void>();
|
||||
readonly Volume2Icon = Volume2;
|
||||
readonly labels = injectChatLabels();
|
||||
|
||||
handleClick(event?: Event): void {
|
||||
event?.stopPropagation();
|
||||
if (!this.disabled()) {
|
||||
this.clicked.emit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Regenerate button component
|
||||
@Component({
|
||||
selector: "copilot-chat-assistant-message-regenerate-button",
|
||||
imports: [LucideAngularModule, CopilotChatAssistantMessageToolbarButton],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
template: `
|
||||
<button
|
||||
copilotChatAssistantMessageToolbarButton
|
||||
[title]="title() || labels.assistantMessageToolbarRegenerateLabel"
|
||||
[disabled]="disabled()"
|
||||
[inputClass]="inputClass()"
|
||||
(click)="handleClick($event)"
|
||||
>
|
||||
<lucide-angular [img]="RefreshCwIcon" [size]="18"></lucide-angular>
|
||||
</button>
|
||||
`,
|
||||
})
|
||||
export class CopilotChatAssistantMessageRegenerateButton {
|
||||
readonly title = input<string | undefined>();
|
||||
readonly disabled = input<boolean>(false);
|
||||
readonly inputClass = input<string | undefined>();
|
||||
readonly clicked = output<void>();
|
||||
readonly RefreshCwIcon = RefreshCw;
|
||||
readonly labels = injectChatLabels();
|
||||
|
||||
handleClick(event?: Event): void {
|
||||
event?.stopPropagation();
|
||||
if (!this.disabled()) {
|
||||
this.clicked.emit();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
import {
|
||||
Component,
|
||||
input,
|
||||
ChangeDetectionStrategy,
|
||||
ViewEncapsulation,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
ElementRef,
|
||||
AfterViewInit,
|
||||
ViewChild,
|
||||
} from "@angular/core";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Marked } from "marked";
|
||||
import hljs from "highlight.js";
|
||||
import * as katex from "katex";
|
||||
import { completePartialMarkdown } from "@copilotkit/core";
|
||||
import { LucideAngularModule } from "lucide-angular";
|
||||
import { copyToClipboard } from "@copilotkit/shared";
|
||||
import { injectChatLabels } from "../../chat-config";
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: "copilot-chat-assistant-message-renderer",
|
||||
imports: [CommonModule, LucideAngularModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
template: `
|
||||
<div
|
||||
#markdownContainer
|
||||
[class]="inputClass()"
|
||||
(click)="handleClick($event)"
|
||||
></div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
copilot-chat-assistant-message-renderer {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Inline code styling */
|
||||
copilot-chat-assistant-message-renderer code:not(pre code) {
|
||||
padding: 2.5px 4.8px;
|
||||
background-color: rgb(236, 236, 236);
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo,
|
||||
monospace;
|
||||
font-weight: 500;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.dark copilot-chat-assistant-message-renderer code:not(pre code) {
|
||||
background-color: #171717; /* same as code blocks */
|
||||
color: rgb(248, 250, 252); /* text-foreground in dark mode */
|
||||
}
|
||||
|
||||
/* Code block container */
|
||||
copilot-chat-assistant-message-renderer .code-block-container {
|
||||
position: relative;
|
||||
margin: 0.25rem 0;
|
||||
background-color: rgb(249, 249, 249);
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.dark copilot-chat-assistant-message-renderer .code-block-container {
|
||||
background-color: #171717;
|
||||
}
|
||||
|
||||
copilot-chat-assistant-message-renderer .code-block-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem 0.75rem 1rem;
|
||||
font-size: 0.75rem;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
copilot-chat-assistant-message-renderer .code-block-language {
|
||||
font-weight: 400;
|
||||
color: rgba(115, 115, 115, 1);
|
||||
}
|
||||
|
||||
.dark copilot-chat-assistant-message-renderer .code-block-language {
|
||||
color: white;
|
||||
}
|
||||
|
||||
copilot-chat-assistant-message-renderer .code-block-copy-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
padding: 0 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: rgba(115, 115, 115, 1);
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.dark copilot-chat-assistant-message-renderer .code-block-copy-button {
|
||||
color: white;
|
||||
}
|
||||
|
||||
copilot-chat-assistant-message-renderer .code-block-copy-button:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
copilot-chat-assistant-message-renderer .code-block-copy-button svg {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
copilot-chat-assistant-message-renderer .code-block-copy-button span {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
copilot-chat-assistant-message-renderer pre {
|
||||
margin: 0;
|
||||
padding: 0 1rem 1rem 1rem;
|
||||
overflow-x: auto;
|
||||
background-color: transparent;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.dark copilot-chat-assistant-message-renderer pre {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
copilot-chat-assistant-message-renderer pre code {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
font-size: 0.875rem;
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", Menlo,
|
||||
monospace;
|
||||
}
|
||||
|
||||
/* Highlight.js theme adjustments */
|
||||
copilot-chat-assistant-message-renderer .hljs {
|
||||
background: transparent;
|
||||
color: rgb(56, 58, 66);
|
||||
}
|
||||
|
||||
.dark copilot-chat-assistant-message-renderer .hljs {
|
||||
background: transparent;
|
||||
color: #abb2bf;
|
||||
}
|
||||
|
||||
/* Math equations */
|
||||
copilot-chat-assistant-message-renderer .katex-display {
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class CopilotChatAssistantMessageRenderer implements AfterViewInit {
|
||||
readonly content = input<string>("");
|
||||
readonly inputClass = input<string | undefined>();
|
||||
readonly labels = injectChatLabels();
|
||||
|
||||
@ViewChild("markdownContainer", { static: false })
|
||||
markdownContainer?: ElementRef<HTMLDivElement>;
|
||||
|
||||
private elementRef = inject(ElementRef);
|
||||
|
||||
// Track copy states for code blocks (DOM-updated; no signal needed)
|
||||
private copyStates = new Map<string, boolean>();
|
||||
|
||||
readonly renderedHtml = computed(() => {
|
||||
const currentContent = this.content();
|
||||
const completedMarkdown = completePartialMarkdown(currentContent);
|
||||
return this.renderMarkdown(completedMarkdown);
|
||||
});
|
||||
|
||||
constructor() {
|
||||
// React to content changes using signals
|
||||
effect(() => {
|
||||
// Read content to establish dependency
|
||||
this.content();
|
||||
// Reset copy states when content changes
|
||||
this.copyStates.clear();
|
||||
// If view is ready, update DOM
|
||||
if (this.markdownContainer) {
|
||||
this.updateContent();
|
||||
this.renderMathEquations();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.updateContent();
|
||||
this.renderMathEquations();
|
||||
}
|
||||
|
||||
private updateContent(): void {
|
||||
if (!this.markdownContainer) return;
|
||||
const container = this.markdownContainer.nativeElement;
|
||||
const html = this.renderedHtml();
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
private codeBlocksMap = new Map<string, string>();
|
||||
private markedInstance: Marked | null = null;
|
||||
|
||||
private initializeMarked(): void {
|
||||
if (this.markedInstance) return;
|
||||
|
||||
// Store highlighted code blocks temporarily
|
||||
const highlightedBlocks = new Map<string, string>();
|
||||
|
||||
// Create a new Marked instance
|
||||
this.markedInstance = new Marked();
|
||||
|
||||
// Configure marked options
|
||||
this.markedInstance.setOptions({
|
||||
gfm: true,
|
||||
breaks: true,
|
||||
});
|
||||
|
||||
// Add a walkTokens function to process code tokens before rendering
|
||||
this.markedInstance.use({
|
||||
walkTokens: (token: any) => {
|
||||
if (token.type === "code") {
|
||||
const rawCode = token.text;
|
||||
const lang = token.lang || "";
|
||||
|
||||
const blockId = this.generateBlockId(rawCode);
|
||||
// Store the raw code in our map for copying
|
||||
this.codeBlocksMap.set(blockId, rawCode);
|
||||
|
||||
const copyLabel = this.labels.assistantMessageToolbarCopyCodeLabel;
|
||||
|
||||
// Manually highlight the code
|
||||
const language = hljs.getLanguage(lang) ? lang : "plaintext";
|
||||
const highlighted = hljs.highlight(rawCode, { language }).value;
|
||||
const codeClass = lang ? `hljs language-${lang}` : "hljs";
|
||||
|
||||
// Create the full HTML with header and highlighted code
|
||||
const fullHtml = `
|
||||
<div class="code-block-container">
|
||||
<div class="code-block-header">
|
||||
${lang ? `<span class="code-block-language">${lang}</span>` : "<span></span>"}
|
||||
<button
|
||||
class="code-block-copy-button"
|
||||
data-code-block-id="${blockId}"
|
||||
aria-label="${copyLabel} code">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.11 0-2-.9-2-2V4c0-1.11.89-2 2-2h10c1.11 0 2 .89 2 2"/></svg>
|
||||
<span>${copyLabel}</span>
|
||||
</button>
|
||||
</div>
|
||||
<pre><code class="${codeClass}">${highlighted}</code></pre>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Store the highlighted HTML
|
||||
highlightedBlocks.set(blockId, fullHtml);
|
||||
|
||||
// Change the token to an html token to bypass marked's escaping
|
||||
token.type = "html";
|
||||
token.text = fullHtml;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private renderMarkdown(content: string): string {
|
||||
// Initialize marked if not already done
|
||||
this.initializeMarked();
|
||||
|
||||
// Clear the code blocks map for new render
|
||||
this.codeBlocksMap.clear();
|
||||
|
||||
// Parse markdown
|
||||
let html = this.markedInstance!.parse(content) as string;
|
||||
|
||||
// Process math equations
|
||||
html = this.processMathEquations(html);
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
private processMathEquations(html: string): string {
|
||||
// First, temporarily replace code blocks with placeholders to protect them from math processing
|
||||
const codeBlocks: string[] = [];
|
||||
const placeholder = "___CODE_BLOCK_PLACEHOLDER_";
|
||||
|
||||
// Store code blocks and replace with placeholders
|
||||
html = html.replace(/<pre><code[\s\S]*?<\/code><\/pre>/g, (match) => {
|
||||
const index = codeBlocks.length;
|
||||
codeBlocks.push(match);
|
||||
return `${placeholder}${index}___`;
|
||||
});
|
||||
|
||||
// Also protect inline code
|
||||
const inlineCode: string[] = [];
|
||||
const inlinePlaceholder = "___INLINE_CODE_PLACEHOLDER_";
|
||||
html = html.replace(/<code>[\s\S]*?<\/code>/g, (match) => {
|
||||
const index = inlineCode.length;
|
||||
inlineCode.push(match);
|
||||
return `${inlinePlaceholder}${index}___`;
|
||||
});
|
||||
|
||||
// Process display math $$ ... $$
|
||||
html = html.replace(/\$\$([\s\S]*?)\$\$/g, (match, equation) => {
|
||||
try {
|
||||
return katex.renderToString(equation, {
|
||||
displayMode: true,
|
||||
throwOnError: false,
|
||||
});
|
||||
} catch {
|
||||
return match;
|
||||
}
|
||||
});
|
||||
|
||||
// Process inline math $ ... $
|
||||
html = html.replace(/\$([^$]+)\$/g, (match, equation) => {
|
||||
try {
|
||||
return katex.renderToString(equation, {
|
||||
displayMode: false,
|
||||
throwOnError: false,
|
||||
});
|
||||
} catch {
|
||||
return match;
|
||||
}
|
||||
});
|
||||
|
||||
// Restore code blocks
|
||||
codeBlocks.forEach((block, index) => {
|
||||
html = html.replace(`${placeholder}${index}___`, block);
|
||||
});
|
||||
|
||||
// Restore inline code
|
||||
inlineCode.forEach((code, index) => {
|
||||
html = html.replace(`${inlinePlaceholder}${index}___`, code);
|
||||
});
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
private renderMathEquations(): void {
|
||||
if (!this.markdownContainer) return;
|
||||
|
||||
const container = this.markdownContainer.nativeElement;
|
||||
|
||||
// Find all math placeholders and render them
|
||||
const mathElements = container.querySelectorAll(".math-placeholder");
|
||||
mathElements.forEach((element) => {
|
||||
const equation = element.getAttribute("data-equation");
|
||||
const displayMode = element.getAttribute("data-display") === "true";
|
||||
|
||||
if (equation) {
|
||||
try {
|
||||
katex.render(equation, element as HTMLElement, {
|
||||
displayMode,
|
||||
throwOnError: false,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to render math equation:", error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
handleClick(event: MouseEvent): void {
|
||||
const target = event.target as HTMLElement;
|
||||
|
||||
// Check if clicked on copy button or its children
|
||||
const copyButton = target.closest(
|
||||
".code-block-copy-button",
|
||||
) as HTMLButtonElement;
|
||||
if (copyButton) {
|
||||
event.preventDefault();
|
||||
const blockId = copyButton.getAttribute("data-code-block-id");
|
||||
|
||||
if (blockId) {
|
||||
// Get the raw code from our map instead of from DOM
|
||||
const code = this.codeBlocksMap.get(blockId);
|
||||
if (code) {
|
||||
this.copyCodeBlock(blockId, code);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private copyCodeBlock(blockId: string, code: string): void {
|
||||
copyToClipboard(code).then((success) => {
|
||||
if (!success) return;
|
||||
|
||||
// Update the button in the DOM
|
||||
const button = this.elementRef.nativeElement.querySelector(
|
||||
`[data-code-block-id="${blockId}"]`,
|
||||
);
|
||||
if (button) {
|
||||
const originalHTML = button.innerHTML;
|
||||
button.innerHTML = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
|
||||
<span>${this.labels.assistantMessageToolbarCopyCodeCopiedLabel}</span>
|
||||
`;
|
||||
button.setAttribute(
|
||||
"aria-label",
|
||||
`${this.labels.assistantMessageToolbarCopyCodeCopiedLabel} code`,
|
||||
);
|
||||
|
||||
// Reset after 2 seconds
|
||||
setTimeout(() => {
|
||||
button.innerHTML = originalHTML;
|
||||
button.setAttribute(
|
||||
"aria-label",
|
||||
`${this.labels.assistantMessageToolbarCopyCodeLabel} code`,
|
||||
);
|
||||
}, 2000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private generateBlockId(code: string): string {
|
||||
// Simple hash function for generating unique IDs
|
||||
let hash = 0;
|
||||
for (let i = 0; i < code.length; i++) {
|
||||
const char = code.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash = hash & hash; // Convert to 32-bit integer
|
||||
}
|
||||
return `code-block-${hash}`;
|
||||
}
|
||||
|
||||
private escapeHtml(text: string): string {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Directive, input, computed } from "@angular/core";
|
||||
import { cn } from "../../utils";
|
||||
|
||||
@Directive({
|
||||
selector: "[copilotChatAssistantMessageToolbar]",
|
||||
host: {
|
||||
"[class]": "computedClass()",
|
||||
},
|
||||
})
|
||||
export class CopilotChatAssistantMessageToolbar {
|
||||
readonly inputClass = input<string | undefined>();
|
||||
|
||||
readonly computedClass = computed(() => {
|
||||
return cn(
|
||||
"cpk:w-full cpk:bg-transparent cpk:flex cpk:items-center cpk:-ml-[5px] cpk:-mt-[0px]",
|
||||
this.inputClass(),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
import {
|
||||
Component,
|
||||
TemplateRef,
|
||||
ContentChild,
|
||||
signal,
|
||||
computed,
|
||||
Type,
|
||||
ChangeDetectionStrategy,
|
||||
ViewEncapsulation,
|
||||
Optional,
|
||||
Inject,
|
||||
input,
|
||||
output,
|
||||
} from "@angular/core";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { CopilotSlot } from "../../slots/copilot-slot";
|
||||
import { CopilotChatToolCallsView } from "./copilot-chat-tool-calls-view";
|
||||
import type { Message } from "@ag-ui/core";
|
||||
import {
|
||||
type AssistantMessage,
|
||||
type CopilotChatAssistantMessageOnThumbsUpProps,
|
||||
type CopilotChatAssistantMessageOnThumbsDownProps,
|
||||
type CopilotChatAssistantMessageOnReadAloudProps,
|
||||
type CopilotChatAssistantMessageOnRegenerateProps,
|
||||
type AssistantMessageMarkdownRendererContext,
|
||||
type AssistantMessageCopyButtonContext,
|
||||
type ThumbsUpButtonContext,
|
||||
type ThumbsDownButtonContext,
|
||||
type ReadAloudButtonContext,
|
||||
type RegenerateButtonContext,
|
||||
type AssistantMessageToolbarContext,
|
||||
} from "./copilot-chat-assistant-message.types";
|
||||
import { CopilotChatAssistantMessageRenderer } from "./copilot-chat-assistant-message-renderer";
|
||||
import {
|
||||
CopilotChatAssistantMessageCopyButton,
|
||||
CopilotChatAssistantMessageThumbsUpButton,
|
||||
CopilotChatAssistantMessageThumbsDownButton,
|
||||
} from "./copilot-chat-assistant-message-buttons";
|
||||
import { CopilotChatAssistantMessageToolbar } from "./copilot-chat-assistant-message-toolbar";
|
||||
import { cn } from "../../utils";
|
||||
import { CopilotChatViewHandlers } from "./copilot-chat-view-handlers";
|
||||
|
||||
@Component({
|
||||
selector: "copilot-chat-assistant-message",
|
||||
host: { "data-copilotkit": "" },
|
||||
imports: [
|
||||
CommonModule,
|
||||
CopilotSlot,
|
||||
CopilotChatAssistantMessageRenderer,
|
||||
CopilotChatAssistantMessageCopyButton,
|
||||
CopilotChatAssistantMessageToolbar,
|
||||
CopilotChatToolCallsView,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
template: `
|
||||
<div [class]="computedClass()" [attr.data-message-id]="message().id">
|
||||
<!-- Markdown Renderer -->
|
||||
@if (markdownRendererTemplate || markdownRendererComponent()) {
|
||||
<copilot-slot
|
||||
[slot]="markdownRendererTemplate || markdownRendererComponent()"
|
||||
[context]="markdownRendererContext()"
|
||||
[defaultComponent]="CopilotChatAssistantMessageRenderer"
|
||||
>
|
||||
</copilot-slot>
|
||||
} @else {
|
||||
<copilot-chat-assistant-message-renderer
|
||||
[content]="message().content || ''"
|
||||
[inputClass]="markdownRendererClass()"
|
||||
>
|
||||
</copilot-chat-assistant-message-renderer>
|
||||
}
|
||||
|
||||
<!-- Tool Calls View -->
|
||||
@if (toolCallsViewTemplate || toolCallsViewComponent()) {
|
||||
<copilot-slot
|
||||
[slot]="toolCallsViewTemplate || toolCallsViewComponent()"
|
||||
[context]="toolCallsViewContext()"
|
||||
[defaultComponent]="CopilotChatToolCallsView"
|
||||
>
|
||||
</copilot-slot>
|
||||
} @else if (message().toolCalls && message()!.toolCalls!.length > 0) {
|
||||
<copilot-chat-tool-calls-view
|
||||
[message]="message()!"
|
||||
[messages]="messages()"
|
||||
[agentId]="agentId()"
|
||||
[isLoading]="isLoading()"
|
||||
>
|
||||
</copilot-chat-tool-calls-view>
|
||||
}
|
||||
|
||||
<!-- Toolbar: show only when there is assistant text content -->
|
||||
@if (toolbarVisible() && hasMessageContent()) {
|
||||
@if (toolbarTemplate || toolbarComponent()) {
|
||||
<copilot-slot
|
||||
[slot]="toolbarTemplate || toolbarComponent()"
|
||||
[context]="toolbarContext()"
|
||||
[defaultComponent]="CopilotChatAssistantMessageToolbar"
|
||||
>
|
||||
</copilot-slot>
|
||||
} @else {
|
||||
<div copilotChatAssistantMessageToolbar [inputClass]="toolbarClass()">
|
||||
<div class="cpk:flex cpk:items-center cpk:gap-1">
|
||||
<!-- Copy button -->
|
||||
@if (copyButtonTemplate || copyButtonComponent()) {
|
||||
<copilot-slot
|
||||
[slot]="copyButtonTemplate || copyButtonComponent()"
|
||||
[context]="{ content: message().content || '' }"
|
||||
[defaultComponent]="CopilotChatAssistantMessageCopyButton"
|
||||
[outputs]="copyButtonOutputs"
|
||||
>
|
||||
</copilot-slot>
|
||||
} @else {
|
||||
<copilot-chat-assistant-message-copy-button
|
||||
[content]="message().content"
|
||||
[inputClass]="copyButtonClass()"
|
||||
(clicked)="handleCopy()"
|
||||
>
|
||||
</copilot-chat-assistant-message-copy-button>
|
||||
}
|
||||
|
||||
<!-- Thumbs up button - show if custom slot provided OR if handler available at top level -->
|
||||
@if (
|
||||
thumbsUpButtonComponent() ||
|
||||
thumbsUpButtonTemplate ||
|
||||
handlers.hasAssistantThumbsUpHandler()
|
||||
) {
|
||||
<copilot-slot
|
||||
[slot]="thumbsUpButtonTemplate || thumbsUpButtonComponent()"
|
||||
[context]="{}"
|
||||
[defaultComponent]="defaultThumbsUpButtonComponent"
|
||||
[outputs]="thumbsUpButtonOutputs"
|
||||
>
|
||||
</copilot-slot>
|
||||
}
|
||||
|
||||
<!-- Thumbs down button - show if custom slot provided OR if handler available at top level -->
|
||||
@if (
|
||||
thumbsDownButtonComponent() ||
|
||||
thumbsDownButtonTemplate ||
|
||||
handlers.hasAssistantThumbsDownHandler()
|
||||
) {
|
||||
<copilot-slot
|
||||
[slot]="thumbsDownButtonTemplate || thumbsDownButtonComponent()"
|
||||
[context]="{}"
|
||||
[defaultComponent]="defaultThumbsDownButtonComponent"
|
||||
[outputs]="thumbsDownButtonOutputs"
|
||||
>
|
||||
</copilot-slot>
|
||||
}
|
||||
|
||||
<!-- Read aloud button - only show if custom slot provided -->
|
||||
@if (readAloudButtonComponent() || readAloudButtonTemplate) {
|
||||
<copilot-slot
|
||||
[slot]="readAloudButtonTemplate || readAloudButtonComponent()"
|
||||
[context]="{}"
|
||||
[outputs]="readAloudButtonOutputs"
|
||||
>
|
||||
</copilot-slot>
|
||||
}
|
||||
|
||||
<!-- Regenerate button - only show if custom slot provided -->
|
||||
@if (regenerateButtonComponent() || regenerateButtonTemplate) {
|
||||
<copilot-slot
|
||||
[slot]="regenerateButtonTemplate || regenerateButtonComponent()"
|
||||
[context]="{}"
|
||||
[outputs]="regenerateButtonOutputs"
|
||||
>
|
||||
</copilot-slot>
|
||||
}
|
||||
|
||||
<!-- Additional toolbar items -->
|
||||
@if (additionalToolbarItems()) {
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="additionalToolbarItems() || null"
|
||||
></ng-container>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
/* Import KaTeX styles */
|
||||
@import "katex/dist/katex.min.css";
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Atom One Light theme for highlight.js */
|
||||
.hljs {
|
||||
color: rgb(56, 58, 66);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.hljs-comment,
|
||||
.hljs-quote {
|
||||
color: #a0a1a7;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.hljs-doctag,
|
||||
.hljs-formula,
|
||||
.hljs-keyword {
|
||||
color: #a626a4;
|
||||
}
|
||||
|
||||
.hljs-deletion,
|
||||
.hljs-name,
|
||||
.hljs-section,
|
||||
.hljs-selector-tag,
|
||||
.hljs-subst {
|
||||
color: #e45649;
|
||||
}
|
||||
|
||||
.hljs-literal {
|
||||
color: #0184bb;
|
||||
}
|
||||
|
||||
.hljs-addition,
|
||||
.hljs-attribute,
|
||||
.hljs-meta .hljs-string,
|
||||
.hljs-regexp,
|
||||
.hljs-string {
|
||||
color: #50a14f;
|
||||
}
|
||||
|
||||
.hljs-attr,
|
||||
.hljs-number,
|
||||
.hljs-selector-attr,
|
||||
.hljs-selector-class,
|
||||
.hljs-selector-pseudo,
|
||||
.hljs-template-variable,
|
||||
.hljs-type,
|
||||
.hljs-variable {
|
||||
color: #986801;
|
||||
}
|
||||
|
||||
.hljs-params {
|
||||
color: rgb(56, 58, 66);
|
||||
}
|
||||
|
||||
.hljs-bullet,
|
||||
.hljs-link,
|
||||
.hljs-meta,
|
||||
.hljs-selector-id,
|
||||
.hljs-symbol,
|
||||
.hljs-title {
|
||||
color: #4078f2;
|
||||
}
|
||||
|
||||
.hljs-built_in,
|
||||
.hljs-class .hljs-title,
|
||||
.hljs-title.class_ {
|
||||
color: #c18401;
|
||||
}
|
||||
|
||||
.hljs-emphasis {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.hljs-strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hljs-link {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Atom One Dark theme for highlight.js */
|
||||
.dark .hljs {
|
||||
color: #abb2bf;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dark .hljs-comment,
|
||||
.dark .hljs-quote {
|
||||
color: #5c6370;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.dark .hljs-doctag,
|
||||
.dark .hljs-formula,
|
||||
.dark .hljs-keyword {
|
||||
color: #c678dd;
|
||||
}
|
||||
|
||||
.dark .hljs-deletion,
|
||||
.dark .hljs-name,
|
||||
.dark .hljs-section,
|
||||
.dark .hljs-selector-tag,
|
||||
.dark .hljs-subst {
|
||||
color: #e06c75;
|
||||
}
|
||||
|
||||
.dark .hljs-literal {
|
||||
color: #56b6c2;
|
||||
}
|
||||
|
||||
.dark .hljs-addition,
|
||||
.dark .hljs-attribute,
|
||||
.dark .hljs-meta .hljs-string,
|
||||
.dark .hljs-regexp,
|
||||
.dark .hljs-string {
|
||||
color: #98c379;
|
||||
}
|
||||
|
||||
.dark .hljs-attr,
|
||||
.dark .hljs-number,
|
||||
.dark .hljs-selector-attr,
|
||||
.dark .hljs-selector-class,
|
||||
.dark .hljs-selector-pseudo,
|
||||
.dark .hljs-template-variable,
|
||||
.dark .hljs-type,
|
||||
.dark .hljs-variable {
|
||||
color: #d19a66;
|
||||
}
|
||||
|
||||
.dark .hljs-bullet,
|
||||
.dark .hljs-link,
|
||||
.dark .hljs-meta,
|
||||
.dark .hljs-selector-id,
|
||||
.dark .hljs-symbol,
|
||||
.dark .hljs-title {
|
||||
color: #61aeee;
|
||||
}
|
||||
|
||||
.dark .hljs-built_in,
|
||||
.dark .hljs-class .hljs-title,
|
||||
.dark .hljs-title.class_ {
|
||||
color: #e6c07b;
|
||||
}
|
||||
|
||||
.dark .hljs-params {
|
||||
color: #abb2bf; /* same as regular text */
|
||||
}
|
||||
|
||||
.dark .hljs-emphasis {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.dark .hljs-strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dark .hljs-link {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class CopilotChatAssistantMessage {
|
||||
// Capture templates from content projection
|
||||
@ContentChild("markdownRenderer", { read: TemplateRef })
|
||||
markdownRendererTemplate?: TemplateRef<AssistantMessageMarkdownRendererContext>;
|
||||
@ContentChild("toolbar", { read: TemplateRef })
|
||||
toolbarTemplate?: TemplateRef<AssistantMessageToolbarContext>;
|
||||
@ContentChild("copyButton", { read: TemplateRef })
|
||||
copyButtonTemplate?: TemplateRef<AssistantMessageCopyButtonContext>;
|
||||
@ContentChild("thumbsUpButton", { read: TemplateRef })
|
||||
thumbsUpButtonTemplate?: TemplateRef<ThumbsUpButtonContext>;
|
||||
@ContentChild("thumbsDownButton", { read: TemplateRef })
|
||||
thumbsDownButtonTemplate?: TemplateRef<ThumbsDownButtonContext>;
|
||||
@ContentChild("readAloudButton", { read: TemplateRef })
|
||||
readAloudButtonTemplate?: TemplateRef<ReadAloudButtonContext>;
|
||||
@ContentChild("regenerateButton", { read: TemplateRef })
|
||||
regenerateButtonTemplate?: TemplateRef<RegenerateButtonContext>;
|
||||
@ContentChild("toolCallsView", { read: TemplateRef })
|
||||
toolCallsViewTemplate?: TemplateRef<any>;
|
||||
|
||||
// Class inputs for styling default components
|
||||
readonly markdownRendererClass = input<string | undefined>(undefined);
|
||||
readonly toolbarClass = input<string | undefined>(undefined);
|
||||
readonly copyButtonClass = input<string | undefined>(undefined);
|
||||
readonly thumbsUpButtonClass = input<string | undefined>(undefined);
|
||||
readonly thumbsDownButtonClass = input<string | undefined>(undefined);
|
||||
readonly readAloudButtonClass = input<string | undefined>(undefined);
|
||||
readonly regenerateButtonClass = input<string | undefined>(undefined);
|
||||
readonly toolCallsViewClass = input<string | undefined>(undefined);
|
||||
|
||||
// Component inputs for overrides
|
||||
readonly markdownRendererComponent = input<Type<any> | undefined>(undefined);
|
||||
readonly toolbarComponent = input<Type<any> | undefined>(undefined);
|
||||
readonly copyButtonComponent = input<Type<any> | undefined>(undefined);
|
||||
readonly thumbsUpButtonComponent = input<Type<any> | undefined>(undefined);
|
||||
readonly thumbsDownButtonComponent = input<Type<any> | undefined>(undefined);
|
||||
readonly readAloudButtonComponent = input<Type<any> | undefined>(undefined);
|
||||
readonly regenerateButtonComponent = input<Type<any> | undefined>(undefined);
|
||||
readonly toolCallsViewComponent = input<Type<any> | undefined>(undefined);
|
||||
|
||||
// Regular inputs
|
||||
readonly message = input.required<AssistantMessage>();
|
||||
readonly messages = input<Message[]>([]);
|
||||
readonly agentId = input<string | undefined>();
|
||||
readonly isLoading = input<boolean>(false);
|
||||
readonly additionalToolbarItems = input<TemplateRef<any> | undefined>(
|
||||
undefined,
|
||||
);
|
||||
readonly toolbarVisible = input<boolean>(true);
|
||||
readonly inputClass = input<string | undefined>(undefined);
|
||||
|
||||
// DI service exposes handler availability scoped to CopilotChatView
|
||||
// Make it optional with a default fallback for testing
|
||||
handlers: CopilotChatViewHandlers;
|
||||
|
||||
constructor(
|
||||
@Optional()
|
||||
@Inject(CopilotChatViewHandlers)
|
||||
handlers?: CopilotChatViewHandlers | null,
|
||||
) {
|
||||
this.handlers = handlers || new CopilotChatViewHandlers();
|
||||
}
|
||||
|
||||
// Output events
|
||||
thumbsUp = output<CopilotChatAssistantMessageOnThumbsUpProps>();
|
||||
thumbsDown = output<CopilotChatAssistantMessageOnThumbsDownProps>();
|
||||
readAloud = output<CopilotChatAssistantMessageOnReadAloudProps>();
|
||||
regenerate = output<CopilotChatAssistantMessageOnRegenerateProps>();
|
||||
|
||||
// Signals
|
||||
customClass = signal<string | undefined>(undefined);
|
||||
|
||||
// Computed values
|
||||
computedClass = computed(() => {
|
||||
return cn(
|
||||
"cpk:prose cpk:max-w-full cpk:break-words cpk:dark:prose-invert",
|
||||
this.customClass(),
|
||||
);
|
||||
});
|
||||
|
||||
// Default components
|
||||
protected readonly defaultThumbsUpButtonComponent =
|
||||
CopilotChatAssistantMessageThumbsUpButton;
|
||||
protected readonly defaultThumbsDownButtonComponent =
|
||||
CopilotChatAssistantMessageThumbsDownButton;
|
||||
protected readonly CopilotChatAssistantMessageRenderer =
|
||||
CopilotChatAssistantMessageRenderer;
|
||||
protected readonly CopilotChatAssistantMessageToolbar =
|
||||
CopilotChatAssistantMessageToolbar;
|
||||
protected readonly CopilotChatAssistantMessageCopyButton =
|
||||
CopilotChatAssistantMessageCopyButton;
|
||||
protected readonly CopilotChatToolCallsView = CopilotChatToolCallsView;
|
||||
|
||||
// Context for slots (reactive via signals)
|
||||
markdownRendererContext = computed<AssistantMessageMarkdownRendererContext>(
|
||||
() => ({
|
||||
content: this.message()?.content || "",
|
||||
}),
|
||||
);
|
||||
|
||||
// Output maps for slots
|
||||
copyButtonOutputs = { clicked: () => this.handleCopy() };
|
||||
thumbsUpButtonOutputs = { clicked: () => this.handleThumbsUp() };
|
||||
thumbsDownButtonOutputs = { clicked: () => this.handleThumbsDown() };
|
||||
readAloudButtonOutputs = { clicked: () => this.handleReadAloud() };
|
||||
regenerateButtonOutputs = { clicked: () => this.handleRegenerate() };
|
||||
|
||||
toolbarContext = computed<AssistantMessageToolbarContext>(() => ({
|
||||
children: null, // Will be populated by the toolbar content
|
||||
}));
|
||||
|
||||
// Return true if assistant message has non-empty text content
|
||||
hasMessageContent(): boolean {
|
||||
return (this.message()?.content ?? "").trim().length > 0;
|
||||
}
|
||||
|
||||
toolCallsViewContext = computed(() => ({
|
||||
message: this.message(),
|
||||
messages: this.messages(),
|
||||
isLoading: this.isLoading(),
|
||||
}));
|
||||
|
||||
handleCopy(): void {
|
||||
// Copy is handled by the button component itself
|
||||
// This is just for any additional logic if needed
|
||||
}
|
||||
|
||||
handleThumbsUp(): void {
|
||||
this.thumbsUp.emit({ message: this.message() });
|
||||
}
|
||||
|
||||
handleThumbsDown(): void {
|
||||
this.thumbsDown.emit({ message: this.message() });
|
||||
}
|
||||
|
||||
handleReadAloud(): void {
|
||||
this.readAloud.emit({ message: this.message() });
|
||||
}
|
||||
|
||||
handleRegenerate(): void {
|
||||
this.regenerate.emit({ message: this.message() });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/* eslint-disable @typescript-eslint/no-empty-object-type */
|
||||
import { AssistantMessage } from "@ag-ui/client";
|
||||
|
||||
// Context interfaces for slots
|
||||
export interface AssistantMessageMarkdownRendererContext {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface AssistantMessageToolbarContext {
|
||||
children?: any;
|
||||
}
|
||||
|
||||
export interface AssistantMessageCopyButtonContext {
|
||||
content?: string;
|
||||
}
|
||||
|
||||
export interface ThumbsUpButtonContext {
|
||||
// Empty context - click handled via outputs map
|
||||
}
|
||||
|
||||
export interface ThumbsDownButtonContext {
|
||||
// Empty context - click handled via outputs map
|
||||
}
|
||||
|
||||
export interface ReadAloudButtonContext {
|
||||
// Empty context - click handled via outputs map
|
||||
}
|
||||
|
||||
export interface RegenerateButtonContext {
|
||||
// Empty context - click handled via outputs map
|
||||
}
|
||||
|
||||
// Event handler props
|
||||
export interface CopilotChatAssistantMessageOnThumbsUpProps {
|
||||
message: AssistantMessage;
|
||||
}
|
||||
|
||||
export interface CopilotChatAssistantMessageOnThumbsDownProps {
|
||||
message: AssistantMessage;
|
||||
}
|
||||
|
||||
export interface CopilotChatAssistantMessageOnReadAloudProps {
|
||||
message: AssistantMessage;
|
||||
}
|
||||
|
||||
export interface CopilotChatAssistantMessageOnRegenerateProps {
|
||||
message: AssistantMessage;
|
||||
}
|
||||
|
||||
// Re-export for convenience
|
||||
export type { AssistantMessage };
|
||||
@@ -0,0 +1,140 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
ViewEncapsulation,
|
||||
} from "@angular/core";
|
||||
import type { Attachment } from "@copilotkit/shared";
|
||||
import {
|
||||
formatFileSize,
|
||||
getDocumentIcon,
|
||||
getSourceUrl,
|
||||
} from "@copilotkit/shared";
|
||||
import { cn } from "../../utils";
|
||||
|
||||
@Component({
|
||||
selector: "copilot-chat-attachment-queue",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
host: { "data-copilotkit": "" },
|
||||
template: `
|
||||
@if (attachments().length > 0) {
|
||||
<div data-testid="copilot-attachment-queue" [class]="computedClass()">
|
||||
@for (attachment of attachments(); track attachment.id) {
|
||||
<div [class]="itemClass(attachment)">
|
||||
@if (attachment.status === "uploading") {
|
||||
<div class="copilotKitAttachmentQueueOverlay">
|
||||
<div class="copilotKitAttachmentQueueSpinner"></div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (attachment.status === "uploading") {
|
||||
<div class="copilotKitAttachmentQueuePreviewPlaceholder"></div>
|
||||
} @else {
|
||||
@switch (attachment.type) {
|
||||
@case ("image") {
|
||||
<img
|
||||
[src]="sourceUrl(attachment)"
|
||||
[alt]="attachment.filename || 'Image attachment'"
|
||||
class="copilotKitAttachmentQueuePreviewImage"
|
||||
/>
|
||||
}
|
||||
@case ("audio") {
|
||||
<div class="copilotKitAttachmentQueuePreviewAudio">
|
||||
<audio
|
||||
[src]="sourceUrl(attachment)"
|
||||
controls
|
||||
preload="metadata"
|
||||
></audio>
|
||||
@if (attachment.filename) {
|
||||
<span class="copilotKitAttachmentQueueFilename">
|
||||
{{ attachment.filename }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@case ("video") {
|
||||
<div class="copilotKitAttachmentQueuePreviewVideo">
|
||||
@if (attachment.thumbnail) {
|
||||
<img
|
||||
[src]="attachment.thumbnail"
|
||||
[alt]="attachment.filename || 'Video thumbnail'"
|
||||
class="copilotKitAttachmentQueuePreviewImage"
|
||||
/>
|
||||
} @else {
|
||||
<video
|
||||
[src]="sourceUrl(attachment)"
|
||||
preload="metadata"
|
||||
muted
|
||||
class="copilotKitAttachmentQueuePreviewImage"
|
||||
></video>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@default {
|
||||
<div class="copilotKitAttachmentQueuePreviewDocument">
|
||||
<div class="copilotKitAttachmentQueueDocIcon">
|
||||
{{ documentIcon(attachment) }}
|
||||
</div>
|
||||
<div class="copilotKitAttachmentQueueDocInfo">
|
||||
<span class="copilotKitAttachmentQueueFilename">
|
||||
{{ attachment.filename || "Document" }}
|
||||
</span>
|
||||
@if (attachment.size != null) {
|
||||
<span class="copilotKitAttachmentQueueFileSize">
|
||||
{{ fileSize(attachment) }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="copilotKitAttachmentQueueRemoveButton"
|
||||
aria-label="Remove attachment"
|
||||
(click)="removeAttachment.emit(attachment.id)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class CopilotChatAttachmentQueue {
|
||||
readonly attachments = input<Attachment[]>([]);
|
||||
readonly inputClass = input<string | undefined>();
|
||||
readonly removeAttachment = output<string>();
|
||||
|
||||
readonly computedClass = computed(() =>
|
||||
cn(
|
||||
"copilotKitAttachmentQueue cpk:flex cpk:flex-wrap cpk:gap-2 cpk:p-2",
|
||||
this.inputClass(),
|
||||
),
|
||||
);
|
||||
|
||||
itemClass(attachment: Attachment): string {
|
||||
return cn(
|
||||
"copilotKitAttachmentQueueItem",
|
||||
`copilotKitAttachmentQueueItem--${attachment.type}`,
|
||||
);
|
||||
}
|
||||
|
||||
sourceUrl(attachment: Attachment): string {
|
||||
return getSourceUrl(attachment.source);
|
||||
}
|
||||
|
||||
documentIcon(attachment: Attachment): string {
|
||||
return getDocumentIcon(attachment.source.mimeType ?? "");
|
||||
}
|
||||
|
||||
fileSize(attachment: Attachment): string {
|
||||
return formatFileSize(attachment.size ?? 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
signal,
|
||||
ViewEncapsulation,
|
||||
} from "@angular/core";
|
||||
import type {
|
||||
AttachmentModality,
|
||||
InputContentSource,
|
||||
} from "@copilotkit/shared";
|
||||
import { getDocumentIcon, getSourceUrl } from "@copilotkit/shared";
|
||||
import { cn } from "../../utils";
|
||||
|
||||
@Component({
|
||||
selector: "copilot-chat-attachment-renderer",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
host: { "data-copilotkit": "" },
|
||||
template: `
|
||||
@switch (type()) {
|
||||
@case ("image") {
|
||||
@if (imageFailed()) {
|
||||
<div [class]="failedImageClass()">
|
||||
<div class="copilotKitImageRenderingErrorMessage">
|
||||
Failed to load image
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div [class]="imageWrapperClass()">
|
||||
<img
|
||||
[src]="sourceUrl()"
|
||||
alt="Image attachment"
|
||||
class="copilotKitImageRenderingImage"
|
||||
(error)="imageFailed.set(true)"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@case ("audio") {
|
||||
<div [class]="audioClass()">
|
||||
<audio [src]="sourceUrl()" controls preload="metadata"></audio>
|
||||
@if (filename()) {
|
||||
<span class="copilotKitAttachmentFilename">
|
||||
{{ filename() }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@case ("video") {
|
||||
<video
|
||||
[src]="sourceUrl()"
|
||||
controls
|
||||
preload="metadata"
|
||||
[class]="videoClass()"
|
||||
></video>
|
||||
}
|
||||
@default {
|
||||
<div [class]="documentClass()">
|
||||
<div class="copilotKitAttachmentDocIcon">{{ documentIcon() }}</div>
|
||||
<div class="copilotKitAttachmentDocInfo">
|
||||
<span class="copilotKitAttachmentDocName">
|
||||
{{ filename() || source().mimeType || "Unknown type" }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class CopilotChatAttachmentRenderer {
|
||||
readonly type = input.required<AttachmentModality>();
|
||||
readonly source = input.required<InputContentSource>();
|
||||
readonly filename = input<string | undefined>();
|
||||
readonly inputClass = input<string | undefined>();
|
||||
|
||||
readonly imageFailed = signal(false);
|
||||
readonly sourceUrl = computed(() => getSourceUrl(this.source()));
|
||||
readonly documentIcon = computed(() =>
|
||||
getDocumentIcon(this.source().mimeType ?? ""),
|
||||
);
|
||||
|
||||
readonly imageWrapperClass = computed(() =>
|
||||
cn("copilotKitImageRendering", this.inputClass()),
|
||||
);
|
||||
readonly failedImageClass = computed(() =>
|
||||
cn(
|
||||
"copilotKitImageRendering copilotKitImageRenderingError",
|
||||
this.inputClass(),
|
||||
),
|
||||
);
|
||||
readonly audioClass = computed(() =>
|
||||
cn("copilotKitAttachment copilotKitAttachmentAudio", this.inputClass()),
|
||||
);
|
||||
readonly videoClass = computed(() =>
|
||||
cn("copilotKitAttachment copilotKitAttachmentVideo", this.inputClass()),
|
||||
);
|
||||
readonly documentClass = computed(() =>
|
||||
cn("copilotKitAttachment copilotKitAttachmentDocument", this.inputClass()),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import {
|
||||
Directive,
|
||||
DestroyRef,
|
||||
ElementRef,
|
||||
HostListener,
|
||||
Renderer2,
|
||||
inject,
|
||||
input,
|
||||
} from "@angular/core";
|
||||
import {
|
||||
exceedsMaxSize,
|
||||
formatFileSize,
|
||||
generateVideoThumbnail,
|
||||
getModalityFromMimeType,
|
||||
matchesAcceptFilter,
|
||||
randomUUID,
|
||||
readFileAsBase64,
|
||||
type Attachment,
|
||||
type AttachmentsConfig,
|
||||
type InputContent,
|
||||
} from "@copilotkit/shared";
|
||||
import { ChatState } from "../../chat-state";
|
||||
|
||||
@Directive({
|
||||
selector: "[copilotChatAttachments]",
|
||||
})
|
||||
export class CopilotChatAttachmentsDirective {
|
||||
readonly config = input<AttachmentsConfig | undefined>();
|
||||
|
||||
private readonly chat = inject(ChatState);
|
||||
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
private readonly renderer = inject(Renderer2);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
private fileInput?: HTMLInputElement;
|
||||
|
||||
private get enabled(): boolean {
|
||||
return this.config()?.enabled ?? false;
|
||||
}
|
||||
|
||||
onDragOver(event: DragEvent): void {
|
||||
if (!this.enabled) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.chat.dragOver.set(true);
|
||||
}
|
||||
|
||||
onDragLeave(event: DragEvent): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.chat.dragOver.set(false);
|
||||
}
|
||||
|
||||
async onDrop(event: DragEvent): Promise<void> {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.chat.dragOver.set(false);
|
||||
|
||||
if (!this.enabled) return;
|
||||
|
||||
const files = Array.from(event.dataTransfer?.files ?? []);
|
||||
if (files.length > 0) {
|
||||
await this.processFiles(files);
|
||||
}
|
||||
}
|
||||
|
||||
@HostListener("document:paste", ["$event"])
|
||||
async onPaste(event: ClipboardEvent): Promise<void> {
|
||||
if (!this.enabled) return;
|
||||
|
||||
const target = event.target as Node | null;
|
||||
if (!target || !this.host.nativeElement.contains(target)) return;
|
||||
|
||||
const accept = this.config()?.accept ?? "*/*";
|
||||
const files = Array.from(event.clipboardData?.items ?? [])
|
||||
.filter((item) => item.kind === "file")
|
||||
.map((item) => item.getAsFile())
|
||||
.filter(
|
||||
(file): file is File => !!file && matchesAcceptFilter(file, accept),
|
||||
);
|
||||
|
||||
if (files.length === 0) return;
|
||||
|
||||
event.preventDefault();
|
||||
await this.processFiles(files);
|
||||
}
|
||||
|
||||
openFilePicker(): void {
|
||||
const input = this.ensureFileInput();
|
||||
input.accept = this.config()?.accept ?? "*/*";
|
||||
input.click();
|
||||
}
|
||||
|
||||
removeAttachment(id: string): void {
|
||||
this.chat.attachments.update((attachments) =>
|
||||
attachments.filter((attachment) => attachment.id !== id),
|
||||
);
|
||||
}
|
||||
|
||||
consume(): Attachment[] {
|
||||
const readyAttachments = this.chat
|
||||
.attachments()
|
||||
.filter((attachment) => attachment.status === "ready");
|
||||
|
||||
if (readyAttachments.length > 0) {
|
||||
this.chat.attachments.update((attachments) =>
|
||||
attachments.filter((attachment) => attachment.status !== "ready"),
|
||||
);
|
||||
}
|
||||
|
||||
if (this.fileInput) {
|
||||
this.fileInput.value = "";
|
||||
}
|
||||
|
||||
return readyAttachments;
|
||||
}
|
||||
|
||||
buildContent(value: string, attachments: Attachment[]): InputContent[] {
|
||||
const content: InputContent[] = [];
|
||||
const trimmed = value.trim();
|
||||
|
||||
if (trimmed) {
|
||||
content.push({ type: "text", text: trimmed });
|
||||
}
|
||||
|
||||
for (const attachment of attachments) {
|
||||
content.push({
|
||||
type: attachment.type,
|
||||
source: attachment.source,
|
||||
metadata: {
|
||||
...(attachment.filename ? { filename: attachment.filename } : {}),
|
||||
...attachment.metadata,
|
||||
},
|
||||
} as InputContent);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
private ensureFileInput(): HTMLInputElement {
|
||||
if (this.fileInput) return this.fileInput;
|
||||
|
||||
const input: HTMLInputElement = this.renderer.createElement("input");
|
||||
this.renderer.setAttribute(input, "type", "file");
|
||||
this.renderer.setProperty(input, "multiple", true);
|
||||
this.renderer.setStyle(input, "display", "none");
|
||||
const unlisten = this.renderer.listen(input, "change", (event: Event) => {
|
||||
void this.handleFileInputChange(event);
|
||||
});
|
||||
this.renderer.appendChild(this.host.nativeElement, input);
|
||||
|
||||
this.destroyRef.onDestroy(() => {
|
||||
unlisten();
|
||||
input.remove();
|
||||
});
|
||||
|
||||
this.fileInput = input;
|
||||
return input;
|
||||
}
|
||||
|
||||
private async handleFileInputChange(event: Event): Promise<void> {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const files = Array.from(input.files ?? []);
|
||||
if (files.length === 0) return;
|
||||
await this.processFiles(files);
|
||||
}
|
||||
|
||||
private async processFiles(files: File[]): Promise<void> {
|
||||
const config = this.config();
|
||||
if (!config?.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const accept = config.accept ?? "*/*";
|
||||
const maxSize = config.maxSize ?? 20 * 1024 * 1024;
|
||||
|
||||
for (const file of files) {
|
||||
if (!matchesAcceptFilter(file, accept)) {
|
||||
config.onUploadFailed?.({
|
||||
reason: "invalid-type",
|
||||
file,
|
||||
message: `File "${file.name}" is not accepted. Supported types: ${accept}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (exceedsMaxSize(file, maxSize)) {
|
||||
config.onUploadFailed?.({
|
||||
reason: "file-too-large",
|
||||
file,
|
||||
message: `File "${
|
||||
file.name
|
||||
}" exceeds the maximum size of ${formatFileSize(maxSize)}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const placeholderId = randomUUID();
|
||||
const modality = getModalityFromMimeType(file.type);
|
||||
const placeholder: Attachment = {
|
||||
id: placeholderId,
|
||||
type: modality,
|
||||
source: { type: "data", value: "", mimeType: file.type },
|
||||
filename: file.name,
|
||||
size: file.size,
|
||||
status: "uploading",
|
||||
};
|
||||
|
||||
this.chat.attachments.update((attachments) => [
|
||||
...attachments,
|
||||
placeholder,
|
||||
]);
|
||||
|
||||
try {
|
||||
const uploadResult = config.onUpload
|
||||
? await config.onUpload(file)
|
||||
: {
|
||||
type: "data" as const,
|
||||
value: await readFileAsBase64(file),
|
||||
mimeType: file.type,
|
||||
};
|
||||
const { metadata, ...source } = uploadResult;
|
||||
const thumbnail =
|
||||
modality === "video" ? await generateVideoThumbnail(file) : undefined;
|
||||
|
||||
this.chat.attachments.update((attachments) =>
|
||||
attachments.map((attachment) =>
|
||||
attachment.id === placeholderId
|
||||
? {
|
||||
...attachment,
|
||||
source,
|
||||
status: "ready",
|
||||
thumbnail,
|
||||
metadata,
|
||||
}
|
||||
: attachment,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
this.chat.attachments.update((attachments) =>
|
||||
attachments.filter((attachment) => attachment.id !== placeholderId),
|
||||
);
|
||||
console.error(`[CopilotKit] Failed to upload "${file.name}":`, error);
|
||||
config.onUploadFailed?.({
|
||||
reason: "upload-failed",
|
||||
file,
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: `Failed to upload "${file.name}"`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
import {
|
||||
Component,
|
||||
DestroyRef,
|
||||
ElementRef,
|
||||
AfterViewInit,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
computed,
|
||||
inject,
|
||||
viewChild,
|
||||
ChangeDetectionStrategy,
|
||||
ViewEncapsulation,
|
||||
} from "@angular/core";
|
||||
import {
|
||||
AudioRecorderState,
|
||||
AudioRecorderError,
|
||||
} from "./copilot-chat-input.types";
|
||||
|
||||
@Component({
|
||||
selector: "copilot-chat-audio-recorder",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
template: `
|
||||
<div [class]="computedClass()">
|
||||
<canvas
|
||||
#canvasRef
|
||||
class="cpk:w-full cpk:h-full"
|
||||
[style.imageRendering]="'pixelated'"
|
||||
></canvas>
|
||||
</div>
|
||||
`,
|
||||
host: {
|
||||
"[class.copilot-chat-audio-recorder]": "true",
|
||||
},
|
||||
})
|
||||
export class CopilotChatAudioRecorder implements AfterViewInit {
|
||||
private readonly canvasRef =
|
||||
viewChild.required<ElementRef<HTMLCanvasElement>>("canvasRef");
|
||||
|
||||
inputClass = input<string | undefined>();
|
||||
inputShowControls = input<boolean>(false);
|
||||
|
||||
stateChange = output<AudioRecorderState>();
|
||||
error = output<AudioRecorderError>();
|
||||
|
||||
readonly state = signal<AudioRecorderState>("idle");
|
||||
|
||||
readonly computedClass = computed(() => {
|
||||
const baseClasses = "cpk:h-11 cpk:w-full cpk:px-5";
|
||||
return `${baseClasses} ${this.inputClass() || ""}`;
|
||||
});
|
||||
|
||||
// Capture resources
|
||||
private mediaRecorder?: MediaRecorder;
|
||||
private stream?: MediaStream;
|
||||
private audioContext?: AudioContext;
|
||||
private analyser?: AnalyserNode;
|
||||
private audioChunks: Blob[] = [];
|
||||
|
||||
// Guards the async window before `state` flips to "recording".
|
||||
private starting = false;
|
||||
|
||||
// Waveform animation state
|
||||
private animationFrameId?: number;
|
||||
private amplitudeHistory: number[] = [];
|
||||
private scrollOffset = 0;
|
||||
private smoothedAmplitude = 0;
|
||||
private fadeOpacity = 0;
|
||||
|
||||
constructor() {
|
||||
inject(DestroyRef).onDestroy(() => this.dispose());
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.startAnimation();
|
||||
}
|
||||
|
||||
/**
|
||||
* Request microphone access and start recording.
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
if (this.starting || this.state() !== "idle") {
|
||||
return;
|
||||
}
|
||||
this.starting = true;
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
this.stream = stream;
|
||||
|
||||
// Wire an analyser for the live waveform.
|
||||
const audioContext = new AudioContext();
|
||||
this.audioContext = audioContext;
|
||||
const source = audioContext.createMediaStreamSource(stream);
|
||||
const analyser = audioContext.createAnalyser();
|
||||
analyser.fftSize = 2048;
|
||||
source.connect(analyser);
|
||||
this.analyser = analyser;
|
||||
|
||||
const mimeType = MediaRecorder.isTypeSupported("audio/webm;codecs=opus")
|
||||
? "audio/webm;codecs=opus"
|
||||
: MediaRecorder.isTypeSupported("audio/webm")
|
||||
? "audio/webm"
|
||||
: MediaRecorder.isTypeSupported("audio/mp4")
|
||||
? "audio/mp4"
|
||||
: "";
|
||||
|
||||
const options: MediaRecorderOptions = mimeType ? { mimeType } : {};
|
||||
const mediaRecorder = new MediaRecorder(stream, options);
|
||||
this.mediaRecorder = mediaRecorder;
|
||||
this.audioChunks = [];
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
this.audioChunks.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.start(100);
|
||||
this.setState("recording");
|
||||
} catch (err) {
|
||||
this.cleanup();
|
||||
const error = this.toRecorderError(err);
|
||||
this.error.emit(error);
|
||||
this.setState("idle");
|
||||
throw error;
|
||||
} finally {
|
||||
this.starting = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop recording and resolve with the captured audio.
|
||||
*/
|
||||
stop(): Promise<Blob> {
|
||||
return new Promise<Blob>((resolve, reject) => {
|
||||
const mediaRecorder = this.mediaRecorder;
|
||||
if (!mediaRecorder || this.state() !== "recording") {
|
||||
reject(new AudioRecorderError("No active recording"));
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState("processing");
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
const mimeType = mediaRecorder.mimeType || "audio/webm";
|
||||
const audioBlob = new Blob(this.audioChunks, { type: mimeType });
|
||||
this.cleanup();
|
||||
this.setState("idle");
|
||||
resolve(audioBlob);
|
||||
};
|
||||
|
||||
mediaRecorder.onerror = () => {
|
||||
this.cleanup();
|
||||
const error = new AudioRecorderError("Recording failed");
|
||||
this.error.emit(error);
|
||||
this.setState("idle");
|
||||
reject(error);
|
||||
};
|
||||
|
||||
mediaRecorder.stop();
|
||||
});
|
||||
}
|
||||
|
||||
getState(): AudioRecorderState {
|
||||
return this.state();
|
||||
}
|
||||
|
||||
/**
|
||||
* Release every capture/animation resource. Safe to call repeatedly.
|
||||
*/
|
||||
dispose(): void {
|
||||
this.cleanup();
|
||||
}
|
||||
|
||||
private setState(state: AudioRecorderState): void {
|
||||
this.state.set(state);
|
||||
this.stateChange.emit(state);
|
||||
}
|
||||
|
||||
private toRecorderError(err: unknown): AudioRecorderError {
|
||||
if (err instanceof Error && err.name === "NotAllowedError") {
|
||||
return new AudioRecorderError("Microphone permission denied");
|
||||
}
|
||||
if (err instanceof Error && err.name === "NotFoundError") {
|
||||
return new AudioRecorderError("No microphone found");
|
||||
}
|
||||
return new AudioRecorderError(
|
||||
err instanceof Error ? err.message : "Failed to start recording",
|
||||
);
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
if (this.animationFrameId) {
|
||||
cancelAnimationFrame(this.animationFrameId);
|
||||
this.animationFrameId = undefined;
|
||||
}
|
||||
if (this.mediaRecorder && this.mediaRecorder.state !== "inactive") {
|
||||
try {
|
||||
this.mediaRecorder.stop();
|
||||
} catch {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
}
|
||||
this.stream?.getTracks().forEach((track) => track.stop());
|
||||
if (this.audioContext && this.audioContext.state !== "closed") {
|
||||
this.audioContext.close().catch(() => {});
|
||||
}
|
||||
this.mediaRecorder = undefined;
|
||||
this.stream = undefined;
|
||||
this.audioContext = undefined;
|
||||
this.analyser = undefined;
|
||||
this.audioChunks = [];
|
||||
this.amplitudeHistory = [];
|
||||
this.scrollOffset = 0;
|
||||
this.smoothedAmplitude = 0;
|
||||
this.fadeOpacity = 0;
|
||||
}
|
||||
|
||||
/** RMS amplitude from time-domain samples (128 is silence). */
|
||||
private calculateAmplitude(dataArray: Uint8Array): number {
|
||||
let sum = 0;
|
||||
for (let i = 0; i < dataArray.length; i++) {
|
||||
const sample = (dataArray[i] ?? 128) / 128 - 1;
|
||||
sum += sample * sample;
|
||||
}
|
||||
return Math.sqrt(sum / dataArray.length);
|
||||
}
|
||||
|
||||
private startAnimation(): void {
|
||||
const canvas = this.canvasRef().nativeElement;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const barWidth = 2;
|
||||
const barGap = 1;
|
||||
const barSpacing = barWidth + barGap;
|
||||
const scrollSpeed = 1 / 3;
|
||||
|
||||
const draw = () => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
if (
|
||||
canvas.width !== rect.width * dpr ||
|
||||
canvas.height !== rect.height * dpr
|
||||
) {
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
}
|
||||
|
||||
const maxBars = Math.floor(rect.width / barSpacing) + 2;
|
||||
|
||||
if (this.analyser && this.state() === "recording") {
|
||||
if (this.amplitudeHistory.length === 0) {
|
||||
this.amplitudeHistory = new Array(maxBars).fill(0);
|
||||
}
|
||||
|
||||
if (this.fadeOpacity < 1) {
|
||||
this.fadeOpacity = Math.min(1, this.fadeOpacity + 0.03);
|
||||
}
|
||||
|
||||
this.scrollOffset += scrollSpeed;
|
||||
|
||||
const bufferLength = this.analyser.fftSize;
|
||||
const dataArray = new Uint8Array(bufferLength);
|
||||
this.analyser.getByteTimeDomainData(dataArray);
|
||||
const rawAmplitude = this.calculateAmplitude(dataArray);
|
||||
|
||||
const attackSpeed = 0.12;
|
||||
const decaySpeed = 0.08;
|
||||
const speed =
|
||||
rawAmplitude > this.smoothedAmplitude ? attackSpeed : decaySpeed;
|
||||
this.smoothedAmplitude +=
|
||||
(rawAmplitude - this.smoothedAmplitude) * speed;
|
||||
|
||||
if (this.scrollOffset >= barSpacing) {
|
||||
this.scrollOffset -= barSpacing;
|
||||
this.amplitudeHistory.push(this.smoothedAmplitude);
|
||||
if (this.amplitudeHistory.length > maxBars) {
|
||||
this.amplitudeHistory = this.amplitudeHistory.slice(-maxBars);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.clearRect(0, 0, rect.width, rect.height);
|
||||
|
||||
const computedStyle = getComputedStyle(canvas);
|
||||
ctx.fillStyle = computedStyle.color;
|
||||
ctx.globalAlpha = this.fadeOpacity;
|
||||
|
||||
const centerY = rect.height / 2;
|
||||
const maxAmplitude = rect.height / 2 - 2;
|
||||
const history = this.amplitudeHistory;
|
||||
|
||||
if (history.length > 0) {
|
||||
const offset = this.scrollOffset;
|
||||
const edgeFadeWidth = 12;
|
||||
|
||||
for (let i = 0; i < history.length; i++) {
|
||||
const amplitude = history[i] ?? 0;
|
||||
const scaledAmplitude = Math.min(amplitude * 4, 1);
|
||||
const barHeight = Math.max(2, scaledAmplitude * maxAmplitude * 2);
|
||||
|
||||
const x = rect.width - (history.length - i) * barSpacing - offset;
|
||||
const y = centerY - barHeight / 2;
|
||||
|
||||
if (x + barWidth > 0 && x < rect.width) {
|
||||
let edgeOpacity = 1;
|
||||
if (x < edgeFadeWidth) {
|
||||
edgeOpacity = Math.max(0, x / edgeFadeWidth);
|
||||
} else if (x > rect.width - edgeFadeWidth) {
|
||||
edgeOpacity = Math.max(0, (rect.width - x) / edgeFadeWidth);
|
||||
}
|
||||
ctx.globalAlpha = this.fadeOpacity * edgeOpacity;
|
||||
ctx.fillRect(x, y, barWidth, barHeight);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.animationFrameId = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
draw();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import {
|
||||
Component,
|
||||
input,
|
||||
output,
|
||||
ChangeDetectionStrategy,
|
||||
signal,
|
||||
computed,
|
||||
ViewEncapsulation,
|
||||
} from "@angular/core";
|
||||
|
||||
import {
|
||||
LucideAngularModule,
|
||||
ArrowUp,
|
||||
Mic,
|
||||
X,
|
||||
Check,
|
||||
Plus,
|
||||
} from "lucide-angular";
|
||||
import { injectChatLabels } from "../../chat-config";
|
||||
import { CopilotTooltip } from "../../directives/tooltip";
|
||||
import { cn } from "../../utils";
|
||||
|
||||
// Base button classes matching React's button variants
|
||||
const buttonBase = cn(
|
||||
"cpk:inline-flex cpk:items-center cpk:justify-center cpk:gap-2 cpk:whitespace-nowrap cpk:rounded-md cpk:text-sm cpk:font-medium",
|
||||
"cpk:transition-all cpk:disabled:pointer-events-none cpk:disabled:opacity-50",
|
||||
"cpk:shrink-0 cpk:outline-none",
|
||||
"cpk:focus-visible:border-ring cpk:focus-visible:ring-ring/50 cpk:focus-visible:ring-[3px]",
|
||||
);
|
||||
|
||||
const chatInputToolbarPrimary = cn(
|
||||
"cpk:cursor-pointer",
|
||||
// Background and text
|
||||
"cpk:bg-black cpk:text-white",
|
||||
// Dark mode
|
||||
"cpk:dark:bg-white cpk:dark:text-black cpk:dark:focus-visible:outline-white",
|
||||
// Shape and sizing
|
||||
"cpk:rounded-full cpk:h-9 cpk:w-9",
|
||||
// Interactions
|
||||
"cpk:transition-colors",
|
||||
// Focus states
|
||||
"cpk:focus:outline-none",
|
||||
// Hover states
|
||||
"cpk:hover:opacity-70 cpk:disabled:hover:opacity-100",
|
||||
// Disabled states
|
||||
"cpk:disabled:cursor-not-allowed cpk:disabled:bg-[#00000014] cpk:disabled:text-[rgb(13,13,13)]",
|
||||
"cpk:dark:disabled:bg-[#454545] cpk:dark:disabled:text-white",
|
||||
);
|
||||
|
||||
const chatInputToolbarSecondary = cn(
|
||||
"cpk:cursor-pointer",
|
||||
// Background and text
|
||||
"cpk:bg-transparent cpk:text-[#444444]",
|
||||
// Dark mode
|
||||
"cpk:dark:text-white cpk:dark:border-[#404040]",
|
||||
// Shape and sizing
|
||||
"cpk:rounded-full cpk:h-9 cpk:w-9",
|
||||
// Interactions
|
||||
"cpk:transition-colors",
|
||||
// Focus states
|
||||
"cpk:focus:outline-none",
|
||||
// Hover states
|
||||
"cpk:hover:bg-[#f8f8f8] cpk:hover:text-[#333333]",
|
||||
"cpk:dark:hover:bg-[#404040] cpk:dark:hover:text-[#FFFFFF]",
|
||||
// Disabled states
|
||||
"cpk:disabled:cursor-not-allowed cpk:disabled:opacity-50",
|
||||
"cpk:disabled:hover:bg-transparent cpk:disabled:hover:text-[#444444]",
|
||||
"cpk:dark:disabled:hover:bg-transparent cpk:dark:disabled:hover:text-[#CCCCCC]",
|
||||
);
|
||||
|
||||
@Component({
|
||||
selector: "copilot-chat-send-button",
|
||||
imports: [LucideAngularModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
template: `
|
||||
<div class="cpk:mr-[10px]">
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="disabled()"
|
||||
[class]="buttonClass"
|
||||
(click)="onClick()"
|
||||
>
|
||||
<lucide-angular [img]="ArrowUpIcon" [size]="18"></lucide-angular>
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
styles: [``],
|
||||
})
|
||||
export class CopilotChatSendButton {
|
||||
disabled = input(false);
|
||||
clicked = output<void>();
|
||||
|
||||
readonly ArrowUpIcon = ArrowUp;
|
||||
buttonClass = cn(buttonBase, chatInputToolbarPrimary);
|
||||
|
||||
onClick(): void {
|
||||
if (!this.disabled) {
|
||||
this.clicked.emit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: "copilot-chat-start-transcribe-button",
|
||||
imports: [LucideAngularModule, CopilotTooltip],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
template: `
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="disabled()"
|
||||
[class]="buttonClass"
|
||||
[copilotTooltip]="label"
|
||||
tooltipPosition="below"
|
||||
(click)="onClick()"
|
||||
>
|
||||
<lucide-angular [img]="MicIcon" [size]="18"></lucide-angular>
|
||||
</button>
|
||||
`,
|
||||
styles: [``],
|
||||
})
|
||||
export class CopilotChatStartTranscribeButton {
|
||||
disabled = input(false);
|
||||
clicked = output<void>();
|
||||
|
||||
readonly labels = injectChatLabels();
|
||||
|
||||
readonly MicIcon = Mic;
|
||||
buttonClass = cn(buttonBase, chatInputToolbarSecondary, "cpk:mr-2");
|
||||
|
||||
get label(): string {
|
||||
return this.labels.chatInputToolbarStartTranscribeButtonLabel;
|
||||
}
|
||||
|
||||
onClick(): void {
|
||||
if (!this.disabled()) {
|
||||
this.clicked.emit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: "copilot-chat-cancel-transcribe-button",
|
||||
imports: [LucideAngularModule, CopilotTooltip],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
template: `
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="disabled()"
|
||||
[class]="buttonClass"
|
||||
[copilotTooltip]="label"
|
||||
tooltipPosition="below"
|
||||
(click)="onClick()"
|
||||
>
|
||||
<lucide-angular [img]="XIcon" [size]="18"></lucide-angular>
|
||||
</button>
|
||||
`,
|
||||
styles: [``],
|
||||
})
|
||||
export class CopilotChatCancelTranscribeButton {
|
||||
disabled = input(false);
|
||||
clicked = output<void>();
|
||||
|
||||
readonly labels = injectChatLabels();
|
||||
|
||||
readonly XIcon = X;
|
||||
buttonClass = cn(buttonBase, chatInputToolbarSecondary, "cpk:mr-2");
|
||||
|
||||
get label(): string {
|
||||
return this.labels.chatInputToolbarCancelTranscribeButtonLabel;
|
||||
}
|
||||
|
||||
onClick(): void {
|
||||
if (!this.disabled()) {
|
||||
this.clicked.emit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: "copilot-chat-finish-transcribe-button",
|
||||
imports: [LucideAngularModule, CopilotTooltip],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
template: `
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="disabled()"
|
||||
[class]="buttonClass"
|
||||
[copilotTooltip]="label"
|
||||
tooltipPosition="below"
|
||||
(click)="onClick()"
|
||||
>
|
||||
<lucide-angular [img]="CheckIcon" [size]="18"></lucide-angular>
|
||||
</button>
|
||||
`,
|
||||
styles: [``],
|
||||
})
|
||||
export class CopilotChatFinishTranscribeButton {
|
||||
disabled = input(false);
|
||||
clicked = output<void>();
|
||||
|
||||
readonly labels = injectChatLabels();
|
||||
|
||||
readonly CheckIcon = Check;
|
||||
buttonClass = cn(buttonBase, chatInputToolbarSecondary, "cpk:mr-[10px]");
|
||||
|
||||
get label(): string {
|
||||
return this.labels.chatInputToolbarFinishTranscribeButtonLabel;
|
||||
}
|
||||
|
||||
onClick(): void {
|
||||
if (!this.disabled()) {
|
||||
this.clicked.emit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: "copilot-chat-add-file-button",
|
||||
imports: [LucideAngularModule, CopilotTooltip],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
template: `
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="disabled()"
|
||||
[class]="buttonClass"
|
||||
[copilotTooltip]="label"
|
||||
tooltipPosition="below"
|
||||
(click)="onClick()"
|
||||
>
|
||||
<lucide-angular [img]="PlusIcon" [size]="20"></lucide-angular>
|
||||
</button>
|
||||
`,
|
||||
styles: [``],
|
||||
})
|
||||
export class CopilotChatAddFileButton {
|
||||
disabled = input(false);
|
||||
clicked = output<void>();
|
||||
|
||||
readonly labels = injectChatLabels();
|
||||
|
||||
readonly PlusIcon = Plus;
|
||||
buttonClass = cn(buttonBase, chatInputToolbarSecondary, "cpk:ml-2");
|
||||
|
||||
get label(): string {
|
||||
return this.labels.chatInputToolbarAddButtonLabel;
|
||||
}
|
||||
|
||||
onClick(): void {
|
||||
if (!this.disabled()) {
|
||||
this.clicked.emit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Base toolbar button component that other buttons can use
|
||||
@Component({
|
||||
selector: "copilot-chat-toolbar-button",
|
||||
imports: [CopilotTooltip],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
template: `
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="disabled()"
|
||||
[class]="computedClass()"
|
||||
[copilotTooltip]="title()"
|
||||
tooltipPosition="below"
|
||||
(click)="onClick()"
|
||||
>
|
||||
<ng-content></ng-content>
|
||||
</button>
|
||||
`,
|
||||
styles: [``],
|
||||
})
|
||||
export class CopilotChatToolbarButton {
|
||||
disabled = signal(false);
|
||||
variant = signal<"primary" | "secondary">("secondary");
|
||||
customClass = signal("");
|
||||
title = signal("");
|
||||
|
||||
clicked = output<void>();
|
||||
|
||||
computedClass = computed(() => {
|
||||
const variantClass =
|
||||
this.variant() === "primary"
|
||||
? chatInputToolbarPrimary
|
||||
: chatInputToolbarSecondary;
|
||||
return cn(buttonBase, variantClass, this.customClass());
|
||||
});
|
||||
|
||||
onClick(): void {
|
||||
if (!this.disabled()) {
|
||||
this.clicked.emit();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { CopilotChatTextarea } from "./copilot-chat-textarea";
|
||||
import { CopilotChatAudioRecorder } from "./copilot-chat-audio-recorder";
|
||||
import {
|
||||
CopilotChatSendButton,
|
||||
CopilotChatStartTranscribeButton,
|
||||
CopilotChatCancelTranscribeButton,
|
||||
CopilotChatFinishTranscribeButton,
|
||||
CopilotChatAddFileButton,
|
||||
} from "./copilot-chat-buttons";
|
||||
import { CopilotChatToolbar } from "./copilot-chat-toolbar";
|
||||
import { CopilotChatToolsMenu } from "./copilot-chat-tools-menu";
|
||||
|
||||
/**
|
||||
* Default components used by CopilotChatInput.
|
||||
* These can be imported and reused when creating custom slot implementations.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { CopilotChatInputDefaults } from '@copilotkit/angular';
|
||||
*
|
||||
* @Component({
|
||||
* template: `
|
||||
* <copilot-chat-input [sendButtonSlot]="CustomSendButton">
|
||||
* </copilot-chat-input>
|
||||
* `
|
||||
* })
|
||||
* export class MyComponent {
|
||||
* CustomSendButton = class extends CopilotChatInputDefaults.SendButton {
|
||||
* // Custom implementation
|
||||
* };
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class CopilotChatInputDefaults {
|
||||
static readonly TextArea = CopilotChatTextarea;
|
||||
static readonly AudioRecorder = CopilotChatAudioRecorder;
|
||||
static readonly SendButton = CopilotChatSendButton;
|
||||
static readonly StartTranscribeButton = CopilotChatStartTranscribeButton;
|
||||
static readonly CancelTranscribeButton = CopilotChatCancelTranscribeButton;
|
||||
static readonly FinishTranscribeButton = CopilotChatFinishTranscribeButton;
|
||||
static readonly AddFileButton = CopilotChatAddFileButton;
|
||||
static readonly Toolbar = CopilotChatToolbar;
|
||||
static readonly ToolsMenu = CopilotChatToolsMenu;
|
||||
}
|
||||
@@ -0,0 +1,615 @@
|
||||
import {
|
||||
Component,
|
||||
TemplateRef,
|
||||
signal,
|
||||
computed,
|
||||
effect,
|
||||
ChangeDetectionStrategy,
|
||||
AfterViewInit,
|
||||
OnDestroy,
|
||||
Type,
|
||||
ViewEncapsulation,
|
||||
contentChild,
|
||||
input,
|
||||
output,
|
||||
viewChild,
|
||||
untracked,
|
||||
} from "@angular/core";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { CopilotSlot } from "../../slots/copilot-slot";
|
||||
import { injectChatLabels } from "../../chat-config";
|
||||
import { LucideAngularModule, ArrowUp } from "lucide-angular";
|
||||
import { CopilotChatTextarea } from "./copilot-chat-textarea";
|
||||
import { CopilotChatAudioRecorder } from "./copilot-chat-audio-recorder";
|
||||
import {
|
||||
CopilotChatStartTranscribeButton,
|
||||
CopilotChatCancelTranscribeButton,
|
||||
CopilotChatFinishTranscribeButton,
|
||||
CopilotChatAddFileButton,
|
||||
} from "./copilot-chat-buttons";
|
||||
import { CopilotChatToolbar } from "./copilot-chat-toolbar";
|
||||
import { CopilotChatToolsMenu } from "./copilot-chat-tools-menu";
|
||||
import type {
|
||||
CopilotChatInputMode,
|
||||
ToolsMenuItem,
|
||||
} from "./copilot-chat-input.types";
|
||||
import { cn } from "../../utils";
|
||||
import { injectChatState } from "../../chat-state";
|
||||
|
||||
/**
|
||||
* Context provided to slot templates
|
||||
*/
|
||||
export interface SendButtonContext {
|
||||
send: () => void;
|
||||
disabled: boolean;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ToolbarContext {
|
||||
mode: CopilotChatInputMode;
|
||||
value: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: "copilot-chat-input",
|
||||
host: { "data-copilotkit": "" },
|
||||
imports: [
|
||||
CommonModule,
|
||||
CopilotSlot,
|
||||
LucideAngularModule,
|
||||
CopilotChatTextarea,
|
||||
CopilotChatAudioRecorder,
|
||||
CopilotChatStartTranscribeButton,
|
||||
CopilotChatCancelTranscribeButton,
|
||||
CopilotChatFinishTranscribeButton,
|
||||
CopilotChatAddFileButton,
|
||||
CopilotChatToolsMenu,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
template: `
|
||||
<ng-template #mainInputArea>
|
||||
@if (computedMode() === "transcribe") {
|
||||
@if (audioRecorderTemplate() || audioRecorderComponent()) {
|
||||
<copilot-slot
|
||||
[slot]="audioRecorderTemplate() || audioRecorderComponent()"
|
||||
[context]="audioRecorderContext()"
|
||||
[defaultComponent]="defaultAudioRecorder"
|
||||
>
|
||||
</copilot-slot>
|
||||
} @else {
|
||||
<copilot-chat-audio-recorder [inputShowControls]="true">
|
||||
</copilot-chat-audio-recorder>
|
||||
}
|
||||
} @else {
|
||||
@if (textAreaTemplate() || textAreaComponent()) {
|
||||
<copilot-slot
|
||||
[slot]="textAreaTemplate() || textAreaComponent()"
|
||||
[context]="textAreaContext()"
|
||||
>
|
||||
</copilot-slot>
|
||||
} @else {
|
||||
<textarea
|
||||
copilotChatTextarea
|
||||
[inputValue]="computedValue()"
|
||||
[inputAutoFocus]="computedAutoFocus()"
|
||||
[inputDisabled]="computedMode() === 'processing'"
|
||||
[inputClass]="defaultTextAreaClass()"
|
||||
[inputMaxRows]="textAreaMaxRows()"
|
||||
[inputPlaceholder]="textAreaPlaceholder()"
|
||||
(keyDown)="handleKeyDown($event)"
|
||||
(valueChange)="handleValueChange($event)"
|
||||
></textarea>
|
||||
}
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
<ng-template #leadingToolbarItems>
|
||||
@if (
|
||||
addFileButtonTemplate() ||
|
||||
addFileButtonComponent() ||
|
||||
toolsButtonTemplate() ||
|
||||
toolsButtonComponent()
|
||||
) {
|
||||
@if (addFileButtonTemplate() || addFileButtonComponent()) {
|
||||
<copilot-slot
|
||||
[slot]="addFileButtonTemplate() || addFileButtonComponent()"
|
||||
[context]="{
|
||||
inputDisabled: addFileButtonDisabled(),
|
||||
}"
|
||||
[outputs]="addFileButtonOutputs"
|
||||
[defaultComponent]="CopilotChatAddFileButton"
|
||||
>
|
||||
</copilot-slot>
|
||||
} @else if (chatState.attachmentsEnabled()) {
|
||||
<copilot-chat-add-file-button
|
||||
[disabled]="addFileButtonDisabled()"
|
||||
(clicked)="handleAddFile()"
|
||||
>
|
||||
</copilot-chat-add-file-button>
|
||||
}
|
||||
@if (computedToolsMenu().length > 0) {
|
||||
@if (toolsButtonTemplate() || toolsButtonComponent()) {
|
||||
<copilot-slot
|
||||
[slot]="toolsButtonTemplate() || toolsButtonComponent()"
|
||||
[context]="toolsContext()"
|
||||
[defaultComponent]="CopilotChatToolsMenu"
|
||||
>
|
||||
</copilot-slot>
|
||||
} @else {
|
||||
<copilot-chat-tools-menu
|
||||
[inputToolsMenu]="computedToolsMenu()"
|
||||
[inputDisabled]="computedMode() === 'transcribe'"
|
||||
>
|
||||
</copilot-chat-tools-menu>
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
<copilot-chat-tools-menu
|
||||
[inputToolsMenu]="computedToolsMenu()"
|
||||
[inputAddFile]="addFileMenuAction()"
|
||||
[inputDisabled]="computedMode() === 'transcribe'"
|
||||
>
|
||||
</copilot-chat-tools-menu>
|
||||
}
|
||||
@if (additionalToolbarItems()) {
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="additionalToolbarItems() || null"
|
||||
></ng-container>
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
<ng-template #trailingToolbarItems>
|
||||
@if (computedMode() === "transcribe") {
|
||||
@if (
|
||||
cancelTranscribeButtonTemplate() || cancelTranscribeButtonComponent()
|
||||
) {
|
||||
<copilot-slot
|
||||
[slot]="
|
||||
cancelTranscribeButtonTemplate() || cancelTranscribeButtonComponent()
|
||||
"
|
||||
[context]="{}"
|
||||
[outputs]="cancelTranscribeButtonOutputs"
|
||||
[defaultComponent]="CopilotChatCancelTranscribeButton"
|
||||
>
|
||||
</copilot-slot>
|
||||
} @else {
|
||||
<copilot-chat-cancel-transcribe-button
|
||||
(clicked)="handleCancelTranscribe()"
|
||||
>
|
||||
</copilot-chat-cancel-transcribe-button>
|
||||
}
|
||||
@if (
|
||||
finishTranscribeButtonTemplate() || finishTranscribeButtonComponent()
|
||||
) {
|
||||
<copilot-slot
|
||||
[slot]="
|
||||
finishTranscribeButtonTemplate() || finishTranscribeButtonComponent()
|
||||
"
|
||||
[context]="{}"
|
||||
[outputs]="finishTranscribeButtonOutputs"
|
||||
[defaultComponent]="CopilotChatFinishTranscribeButton"
|
||||
>
|
||||
</copilot-slot>
|
||||
} @else {
|
||||
<copilot-chat-finish-transcribe-button
|
||||
(clicked)="handleFinishTranscribe()"
|
||||
>
|
||||
</copilot-chat-finish-transcribe-button>
|
||||
}
|
||||
} @else {
|
||||
@if (startTranscribeButtonTemplate() || startTranscribeButtonComponent()) {
|
||||
<copilot-slot
|
||||
[slot]="
|
||||
startTranscribeButtonTemplate() || startTranscribeButtonComponent()
|
||||
"
|
||||
[context]="{}"
|
||||
[outputs]="startTranscribeButtonOutputs"
|
||||
[defaultComponent]="CopilotChatStartTranscribeButton"
|
||||
>
|
||||
</copilot-slot>
|
||||
} @else {
|
||||
<copilot-chat-start-transcribe-button (clicked)="handleStartTranscribe()">
|
||||
</copilot-chat-start-transcribe-button>
|
||||
}
|
||||
<!-- Send button with slot -->
|
||||
@if (sendButtonTemplate() || sendButtonComponent()) {
|
||||
<copilot-slot
|
||||
[slot]="sendButtonTemplate() || sendButtonComponent()"
|
||||
[context]="sendButtonContext()"
|
||||
[outputs]="sendButtonOutputs"
|
||||
>
|
||||
</copilot-slot>
|
||||
} @else {
|
||||
<div class="cpk:mr-[10px]">
|
||||
<button
|
||||
type="button"
|
||||
[class]="sendButtonClass() || defaultButtonClass"
|
||||
[disabled]="sendButtonDisabled()"
|
||||
(click)="send()"
|
||||
>
|
||||
<lucide-angular [img]="ArrowUpIcon" [size]="18"></lucide-angular>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
<div [class]="computedClass()">
|
||||
@if (toolbarTemplate() || toolbarComponent()) {
|
||||
<ng-container [ngTemplateOutlet]="mainInputArea"></ng-container>
|
||||
<copilot-slot
|
||||
[slot]="toolbarTemplate() || toolbarComponent()"
|
||||
[context]="toolbarContext()"
|
||||
[defaultComponent]="CopilotChatToolbar"
|
||||
>
|
||||
</copilot-slot>
|
||||
} @else {
|
||||
<div
|
||||
class="cpk:grid cpk:w-full cpk:grid-cols-[auto_minmax(0,1fr)_auto] cpk:items-center cpk:gap-x-3 cpk:gap-y-3 cpk:px-3 cpk:py-2"
|
||||
data-layout="compact"
|
||||
>
|
||||
<div class="cpk:col-start-1 cpk:row-start-1 cpk:flex cpk:items-center">
|
||||
<ng-container [ngTemplateOutlet]="leadingToolbarItems"></ng-container>
|
||||
</div>
|
||||
<div
|
||||
class="cpk:relative cpk:col-start-2 cpk:row-start-1 cpk:flex cpk:min-h-[50px] cpk:min-w-0 cpk:flex-col cpk:justify-center"
|
||||
>
|
||||
<ng-container [ngTemplateOutlet]="mainInputArea"></ng-container>
|
||||
</div>
|
||||
<div
|
||||
class="cpk:col-start-3 cpk:row-start-1 cpk:flex cpk:items-center cpk:justify-end cpk:gap-2"
|
||||
>
|
||||
<ng-container [ngTemplateOutlet]="trailingToolbarItems"></ng-container>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
.ck-input-shadow {
|
||||
box-shadow:
|
||||
0 4px 4px 0 #0000000a,
|
||||
0 0 1px 0 #0000009e !important;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class CopilotChatInput implements AfterViewInit, OnDestroy {
|
||||
readonly textAreaRef = viewChild(CopilotChatTextarea);
|
||||
|
||||
readonly audioRecorderRef = viewChild(CopilotChatAudioRecorder);
|
||||
|
||||
// Capture templates from content projection
|
||||
readonly sendButtonTemplate = contentChild<
|
||||
unknown,
|
||||
TemplateRef<SendButtonContext>
|
||||
>("sendButton", { read: TemplateRef });
|
||||
readonly toolbarTemplate = contentChild<unknown, TemplateRef<ToolbarContext>>(
|
||||
"toolbar",
|
||||
{ read: TemplateRef },
|
||||
);
|
||||
readonly textAreaTemplate = contentChild<unknown, TemplateRef<any>>(
|
||||
"textArea",
|
||||
{ read: TemplateRef },
|
||||
);
|
||||
readonly audioRecorderTemplate = contentChild<unknown, TemplateRef<any>>(
|
||||
"audioRecorder",
|
||||
{ read: TemplateRef },
|
||||
);
|
||||
readonly startTranscribeButtonTemplate = contentChild<
|
||||
unknown,
|
||||
TemplateRef<any>
|
||||
>("startTranscribeButton", { read: TemplateRef });
|
||||
readonly cancelTranscribeButtonTemplate = contentChild<
|
||||
unknown,
|
||||
TemplateRef<any>
|
||||
>("cancelTranscribeButton", { read: TemplateRef });
|
||||
readonly finishTranscribeButtonTemplate = contentChild<
|
||||
unknown,
|
||||
TemplateRef<any>
|
||||
>("finishTranscribeButton", { read: TemplateRef });
|
||||
readonly addFileButtonTemplate = contentChild<unknown, TemplateRef<any>>(
|
||||
"addFileButton",
|
||||
{ read: TemplateRef },
|
||||
);
|
||||
readonly toolsButtonTemplate = contentChild<unknown, TemplateRef<any>>(
|
||||
"toolsButton",
|
||||
{ read: TemplateRef },
|
||||
);
|
||||
|
||||
// Class inputs for styling default components
|
||||
sendButtonClass = input<string | undefined>(undefined);
|
||||
toolbarClass = input<string | undefined>(undefined);
|
||||
textAreaClass = input<string | undefined>(undefined);
|
||||
textAreaMaxRows = input<number | undefined>(undefined);
|
||||
textAreaPlaceholder = input<string | undefined>(undefined);
|
||||
audioRecorderClass = input<string | undefined>(undefined);
|
||||
startTranscribeButtonClass = input<string | undefined>(undefined);
|
||||
cancelTranscribeButtonClass = input<string | undefined>(undefined);
|
||||
finishTranscribeButtonClass = input<string | undefined>(undefined);
|
||||
addFileButtonClass = input<string | undefined>(undefined);
|
||||
toolsButtonClass = input<string | undefined>(undefined);
|
||||
|
||||
// Component inputs for overrides
|
||||
sendButtonComponent = input<Type<any> | undefined>(undefined);
|
||||
toolbarComponent = input<Type<any> | undefined>(undefined);
|
||||
textAreaComponent = input<Type<any> | undefined>(undefined);
|
||||
audioRecorderComponent = input<Type<any> | undefined>(undefined);
|
||||
startTranscribeButtonComponent = input<Type<any> | undefined>(undefined);
|
||||
cancelTranscribeButtonComponent = input<Type<any> | undefined>(undefined);
|
||||
finishTranscribeButtonComponent = input<Type<any> | undefined>(undefined);
|
||||
addFileButtonComponent = input<Type<any> | undefined>(undefined);
|
||||
toolsButtonComponent = input<Type<any> | undefined>(undefined);
|
||||
|
||||
// Regular inputs
|
||||
mode = input<CopilotChatInputMode | undefined>(undefined);
|
||||
toolsMenu = input<(ToolsMenuItem | "-")[] | undefined>(undefined);
|
||||
autoFocus = input<boolean | undefined>(undefined);
|
||||
value = input<string | undefined>(undefined);
|
||||
inputClass = input<string | undefined>(undefined);
|
||||
// Note: Prefer host `class` for styling this component;
|
||||
// keep only `inputClass` to style the internal wrapper if needed.
|
||||
additionalToolbarItems = input<TemplateRef<any> | undefined>(undefined);
|
||||
|
||||
// Output events
|
||||
submitMessage = output<string>();
|
||||
startTranscribe = output<void>();
|
||||
cancelTranscribe = output<void>();
|
||||
finishTranscribe = output<void>();
|
||||
finishTranscribeWithAudio = output<Blob>();
|
||||
addFile = output<void>();
|
||||
valueChange = output<string>();
|
||||
|
||||
// Icons and default classes
|
||||
readonly ArrowUpIcon = ArrowUp;
|
||||
readonly defaultButtonClass = cn(
|
||||
// Base button styles
|
||||
"cpk:inline-flex cpk:items-center cpk:justify-center cpk:gap-2 cpk:whitespace-nowrap cpk:rounded-md cpk:text-sm cpk:font-medium",
|
||||
"cpk:transition-all cpk:disabled:pointer-events-none cpk:disabled:opacity-50",
|
||||
"cpk:shrink-0 cpk:outline-none",
|
||||
"cpk:focus-visible:border-ring cpk:focus-visible:ring-ring/50 cpk:focus-visible:ring-[3px]",
|
||||
// chatInputToolbarPrimary variant
|
||||
"cpk:cursor-pointer",
|
||||
"cpk:bg-black cpk:text-white",
|
||||
"cpk:dark:bg-white cpk:dark:text-black cpk:dark:focus-visible:outline-white",
|
||||
"cpk:rounded-full cpk:h-9 cpk:w-9",
|
||||
"cpk:transition-colors",
|
||||
"cpk:focus:outline-none",
|
||||
"cpk:hover:opacity-70 cpk:disabled:hover:opacity-100",
|
||||
"cpk:disabled:cursor-not-allowed cpk:disabled:bg-[#00000014] cpk:disabled:text-[rgb(13,13,13)]",
|
||||
"cpk:dark:disabled:bg-[#454545] cpk:dark:disabled:text-white",
|
||||
);
|
||||
|
||||
// Services
|
||||
readonly labels = injectChatLabels();
|
||||
// readonly chatConfig = injectChatConfig();
|
||||
readonly chatState = injectChatState();
|
||||
|
||||
// Signals
|
||||
modeSignal = signal<CopilotChatInputMode>("input");
|
||||
toolsMenuSignal = signal<(ToolsMenuItem | "-")[]>([]);
|
||||
autoFocusSignal = signal<boolean>(true);
|
||||
customClass = signal<string | undefined>(undefined);
|
||||
|
||||
// Default components
|
||||
// Note: CopilotChatTextarea uses attribute selector but is a component
|
||||
defaultAudioRecorder = CopilotChatAudioRecorder;
|
||||
defaultSendButton: any = null; // Will be set to avoid circular dependency
|
||||
CopilotChatToolbar = CopilotChatToolbar;
|
||||
CopilotChatAddFileButton = CopilotChatAddFileButton;
|
||||
CopilotChatToolsMenu = CopilotChatToolsMenu;
|
||||
CopilotChatCancelTranscribeButton = CopilotChatCancelTranscribeButton;
|
||||
CopilotChatFinishTranscribeButton = CopilotChatFinishTranscribeButton;
|
||||
CopilotChatStartTranscribeButton = CopilotChatStartTranscribeButton;
|
||||
|
||||
// Computed values
|
||||
computedMode = computed(() => this.modeSignal());
|
||||
computedToolsMenu = computed(() => this.toolsMenu() ?? []);
|
||||
computedAutoFocus = computed(() => this.autoFocus() ?? true);
|
||||
computedValue = computed(() => {
|
||||
const customValue = this.value() ?? "";
|
||||
const configValue = this.chatState.inputValue();
|
||||
return customValue || configValue || "";
|
||||
});
|
||||
addFileButtonDisabled = computed(
|
||||
() =>
|
||||
this.computedMode() === "transcribe" ||
|
||||
!this.chatState.attachmentsEnabled(),
|
||||
);
|
||||
addFileMenuAction = computed<(() => void) | undefined>(() =>
|
||||
this.chatState.attachmentsEnabled()
|
||||
? () => this.handleAddFile()
|
||||
: undefined,
|
||||
);
|
||||
sendButtonDisabled = computed(
|
||||
() =>
|
||||
!this.computedValue().trim() ||
|
||||
this.computedMode() === "processing" ||
|
||||
this.chatState.attachmentsUploading(),
|
||||
);
|
||||
|
||||
computedClass = computed(() => {
|
||||
const baseClasses = cn(
|
||||
// V1 compatibility class for custom styling
|
||||
"copilotKitInput",
|
||||
// Layout
|
||||
"cpk:flex cpk:w-full cpk:flex-col cpk:items-center cpk:justify-center",
|
||||
// Interaction
|
||||
"cpk:cursor-text",
|
||||
// Overflow and clipping
|
||||
"cpk:overflow-visible cpk:bg-clip-padding cpk:contain-inline-size",
|
||||
// Background
|
||||
"cpk:bg-white cpk:dark:bg-[#303030]",
|
||||
// Visual effects
|
||||
"ck-input-shadow cpk:rounded-[28px]",
|
||||
);
|
||||
return cn(baseClasses, this.customClass());
|
||||
});
|
||||
|
||||
defaultTextAreaClass = computed(() =>
|
||||
cn("cpk:w-full cpk:py-3 cpk:pr-5", this.textAreaClass()),
|
||||
);
|
||||
|
||||
// Context for slots (reactive via signals)
|
||||
sendButtonContext = computed<SendButtonContext>(() => ({
|
||||
send: () => this.send(),
|
||||
disabled: this.sendButtonDisabled(),
|
||||
value: this.computedValue(),
|
||||
}));
|
||||
|
||||
toolbarContext = computed<ToolbarContext>(() => ({
|
||||
mode: this.computedMode(),
|
||||
value: this.computedValue(),
|
||||
}));
|
||||
|
||||
textAreaContext = computed(() => ({
|
||||
value: this.computedValue(),
|
||||
autoFocus: this.computedAutoFocus(),
|
||||
disabled: this.computedMode() === "processing",
|
||||
maxRows: this.textAreaMaxRows(),
|
||||
placeholder: this.textAreaPlaceholder(),
|
||||
inputClass: this.textAreaClass(),
|
||||
onKeyDown: (event: KeyboardEvent) => this.handleKeyDown(event),
|
||||
onChange: (value: string) => this.handleValueChange(value),
|
||||
}));
|
||||
|
||||
audioRecorderContext = computed(() => ({
|
||||
inputShowControls: true,
|
||||
}));
|
||||
|
||||
// Button contexts removed - now using outputs map for click handlers
|
||||
|
||||
toolsContext = computed(() => ({
|
||||
inputToolsMenu: this.computedToolsMenu(),
|
||||
inputDisabled: this.computedMode() === "transcribe",
|
||||
}));
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const recorder = this.audioRecorderRef();
|
||||
const mode = this.computedMode();
|
||||
if (!recorder) return;
|
||||
untracked(() => {
|
||||
if (mode === "transcribe") {
|
||||
if (recorder.getState() === "idle") {
|
||||
recorder.start().catch((error) => console.error(error));
|
||||
}
|
||||
} else if (recorder.getState() === "recording") {
|
||||
recorder.stop().catch((error) => console.error(error));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Output maps for slots
|
||||
addFileButtonOutputs = { clicked: () => this.handleAddFile() };
|
||||
cancelTranscribeButtonOutputs = {
|
||||
clicked: () => this.handleCancelTranscribe(),
|
||||
};
|
||||
finishTranscribeButtonOutputs = {
|
||||
clicked: () => this.handleFinishTranscribe(),
|
||||
};
|
||||
startTranscribeButtonOutputs = {
|
||||
clicked: () => this.handleStartTranscribe(),
|
||||
};
|
||||
// Support both `clicked` (idiomatic in our slots) and `click` (legacy)
|
||||
sendButtonOutputs = { clicked: () => this.send(), click: () => this.send() };
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
// Auto-focus if needed
|
||||
if (this.computedAutoFocus() && this.textAreaRef()) {
|
||||
setTimeout(() => {
|
||||
this.textAreaRef()?.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
// Clean up any resources
|
||||
const recorder = this.audioRecorderRef();
|
||||
if (recorder?.getState() === "recording") {
|
||||
recorder.stop().catch(console.error);
|
||||
}
|
||||
}
|
||||
|
||||
handleKeyDown(event: KeyboardEvent): void {
|
||||
// Skip key handling during IME composition (e.g. CJK input).
|
||||
// The compositionend event will fire separately when composition ends.
|
||||
if (event.isComposing || event.keyCode === 229) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
this.send();
|
||||
}
|
||||
}
|
||||
|
||||
handleValueChange(value: string): void {
|
||||
this.valueChange.emit(value);
|
||||
if (this.chatState) this.chatState.changeInput(value);
|
||||
}
|
||||
|
||||
send(): void {
|
||||
const trimmed = this.computedValue().trim();
|
||||
if (trimmed && !this.chatState.attachmentsUploading()) {
|
||||
this.submitMessage.emit(trimmed);
|
||||
|
||||
this.chatState.submitInput(trimmed);
|
||||
|
||||
if (this.chatState) this.chatState.changeInput("");
|
||||
this.textAreaRef()?.setValue("");
|
||||
|
||||
// Refocus input
|
||||
if (this.textAreaRef()) {
|
||||
setTimeout(() => {
|
||||
this.textAreaRef()?.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleStartTranscribe(): void {
|
||||
this.startTranscribe.emit();
|
||||
this.modeSignal.set("transcribe");
|
||||
}
|
||||
|
||||
handleCancelTranscribe(): void {
|
||||
this.cancelTranscribe.emit();
|
||||
this.modeSignal.set("input");
|
||||
}
|
||||
|
||||
async handleFinishTranscribe(): Promise<void> {
|
||||
const recorder = this.audioRecorderRef();
|
||||
let audioBlob: Blob | undefined;
|
||||
if (recorder?.getState() === "recording") {
|
||||
try {
|
||||
audioBlob = await recorder.stop();
|
||||
} catch (error) {
|
||||
console.error("Failed to stop recording:", error);
|
||||
}
|
||||
}
|
||||
|
||||
this.finishTranscribe.emit();
|
||||
this.modeSignal.set("input");
|
||||
|
||||
if (audioBlob) {
|
||||
this.finishTranscribeWithAudio.emit(audioBlob);
|
||||
await this.chatState.finishTranscription(audioBlob);
|
||||
}
|
||||
}
|
||||
|
||||
handleAddFile(): void {
|
||||
if (this.addFileButtonDisabled()) {
|
||||
return;
|
||||
}
|
||||
this.addFile.emit();
|
||||
this.chatState.addFile();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { Type, TemplateRef } from "@angular/core";
|
||||
|
||||
/**
|
||||
* Mode of the chat input component
|
||||
*/
|
||||
export type CopilotChatInputMode = "input" | "transcribe" | "processing";
|
||||
|
||||
/**
|
||||
* Represents a menu item in the tools menu
|
||||
*/
|
||||
export type ToolsMenuItem = {
|
||||
label: string;
|
||||
} & (
|
||||
| {
|
||||
action: () => void;
|
||||
items?: never;
|
||||
}
|
||||
| {
|
||||
action?: never;
|
||||
items: (ToolsMenuItem | "-")[];
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Audio recorder state
|
||||
*/
|
||||
export type AudioRecorderState = "idle" | "recording" | "processing";
|
||||
|
||||
/**
|
||||
* Error class for audio recorder failures
|
||||
*/
|
||||
export class AudioRecorderError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "AudioRecorderError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for textarea component
|
||||
*/
|
||||
export interface CopilotChatTextareaProps {
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
maxRows?: number;
|
||||
autoFocus?: boolean;
|
||||
disabled?: boolean;
|
||||
onChange?: (value: string) => void;
|
||||
onKeyDown?: (event: KeyboardEvent) => void;
|
||||
inputClass?: string;
|
||||
style?: any;
|
||||
rows?: number;
|
||||
cols?: number;
|
||||
readonly?: boolean;
|
||||
spellcheck?: boolean;
|
||||
wrap?: "hard" | "soft" | "off";
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for button components
|
||||
*/
|
||||
export interface CopilotChatButtonProps {
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
inputClass?: string;
|
||||
style?: any;
|
||||
type?: "button" | "submit" | "reset";
|
||||
ariaLabel?: string;
|
||||
ariaPressed?: boolean;
|
||||
ariaExpanded?: boolean;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for toolbar button with tooltip
|
||||
*/
|
||||
export interface CopilotChatToolbarButtonProps extends CopilotChatButtonProps {
|
||||
icon?: TemplateRef<any>;
|
||||
tooltip?: string;
|
||||
variant?: "primary" | "secondary";
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for tools menu button
|
||||
*/
|
||||
export interface CopilotChatToolsButtonProps extends CopilotChatButtonProps {
|
||||
toolsMenu?: (ToolsMenuItem | "-")[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for audio recorder
|
||||
*/
|
||||
export interface CopilotChatAudioRecorderProps {
|
||||
inputClass?: string;
|
||||
style?: any;
|
||||
onStateChange?: (state: AudioRecorderState) => void;
|
||||
showControls?: boolean;
|
||||
maxDuration?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for toolbar
|
||||
*/
|
||||
export interface CopilotChatToolbarProps {
|
||||
inputClass?: string;
|
||||
style?: any;
|
||||
position?: "top" | "bottom";
|
||||
alignment?: "left" | "center" | "right" | "space-between";
|
||||
}
|
||||
|
||||
/**
|
||||
* Slot configuration for chat input
|
||||
*/
|
||||
export interface CopilotChatInputSlots {
|
||||
textArea?: Type<any> | TemplateRef<any>;
|
||||
sendButton?: Type<any> | TemplateRef<any>;
|
||||
startTranscribeButton?: Type<any> | TemplateRef<any>;
|
||||
cancelTranscribeButton?: Type<any> | TemplateRef<any>;
|
||||
finishTranscribeButton?: Type<any> | TemplateRef<any>;
|
||||
addFileButton?: Type<any> | TemplateRef<any>;
|
||||
toolsButton?: Type<any> | TemplateRef<any>;
|
||||
toolbar?: Type<any> | TemplateRef<any>;
|
||||
audioRecorder?: Type<any> | TemplateRef<any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input configuration for the chat input component
|
||||
*/
|
||||
export interface CopilotChatInputConfig {
|
||||
mode?: CopilotChatInputMode;
|
||||
toolsMenu?: (ToolsMenuItem | "-")[];
|
||||
autoFocus?: boolean;
|
||||
additionalToolbarItems?: TemplateRef<any>;
|
||||
value?: string;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Output events for the chat input component
|
||||
*/
|
||||
export interface CopilotChatInputOutputs {
|
||||
submitMessage: (value: string) => void;
|
||||
startTranscribe: () => void;
|
||||
cancelTranscribe: () => void;
|
||||
finishTranscribe: () => void;
|
||||
addFile: () => void;
|
||||
changeValue: (value: string) => void;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import {
|
||||
Component,
|
||||
input,
|
||||
ChangeDetectionStrategy,
|
||||
ViewEncapsulation,
|
||||
computed,
|
||||
} from "@angular/core";
|
||||
|
||||
import { cn } from "../../utils";
|
||||
|
||||
/**
|
||||
* Cursor component that matches the React implementation exactly.
|
||||
* Shows a pulsing dot animation to indicate activity.
|
||||
*/
|
||||
@Component({
|
||||
selector: "copilot-chat-message-view-cursor",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
template: `
|
||||
<div data-testid="copilot-loading-cursor" [class]="computedClass()"></div>
|
||||
`,
|
||||
})
|
||||
export class CopilotChatMessageViewCursor {
|
||||
inputClass = input<string | undefined>();
|
||||
|
||||
// Computed class that matches React exactly, with the Angular package Tailwind prefix.
|
||||
computedClass = computed(() =>
|
||||
cn(
|
||||
"cpk:w-[11px] cpk:h-[11px] cpk:rounded-full cpk:bg-foreground cpk:animate-pulse-cursor cpk:ml-1",
|
||||
this.inputClass(),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import {
|
||||
Component,
|
||||
input,
|
||||
output,
|
||||
ContentChild,
|
||||
TemplateRef,
|
||||
Type,
|
||||
ChangeDetectionStrategy,
|
||||
ViewEncapsulation,
|
||||
computed,
|
||||
inject,
|
||||
} from "@angular/core";
|
||||
import { NgComponentOutlet, NgTemplateOutlet } from "@angular/common";
|
||||
import { CopilotSlot } from "../../slots/copilot-slot";
|
||||
import type { ActivityMessage, Message, ReasoningMessage } from "@ag-ui/core";
|
||||
import { CopilotChatAssistantMessage } from "./copilot-chat-assistant-message";
|
||||
import { CopilotChatUserMessage } from "./copilot-chat-user-message";
|
||||
import { CopilotChatMessageViewCursor } from "./copilot-chat-message-view-cursor";
|
||||
import { CopilotChatReasoningMessage } from "./copilot-chat-reasoning-message";
|
||||
import { cn } from "../../utils";
|
||||
import { CopilotKit } from "../../copilotkit";
|
||||
import type { RenderActivityMessageConfig } from "../../activity-renderer";
|
||||
|
||||
/**
|
||||
* CopilotChatMessageView component - Angular port of the React component.
|
||||
* Renders a list of chat messages with support for custom slots and layouts.
|
||||
* DOM structure and Tailwind classes match the React implementation exactly.
|
||||
*/
|
||||
@Component({
|
||||
selector: "copilot-chat-message-view",
|
||||
host: { "data-copilotkit": "" },
|
||||
imports: [
|
||||
NgTemplateOutlet,
|
||||
NgComponentOutlet,
|
||||
CopilotSlot,
|
||||
CopilotChatAssistantMessage,
|
||||
CopilotChatUserMessage,
|
||||
CopilotChatReasoningMessage,
|
||||
CopilotChatMessageViewCursor,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
template: `
|
||||
<!-- Custom layout template support (render prop pattern) -->
|
||||
@if (customLayoutTemplate) {
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="customLayoutTemplate"
|
||||
[ngTemplateOutletContext]="layoutContext()"
|
||||
></ng-container>
|
||||
} @else {
|
||||
<!-- Default layout - exact React DOM structure: div with "flex flex-col" classes -->
|
||||
<div [class]="computedClass()">
|
||||
<!-- Message iteration - simplified without tool calls -->
|
||||
@for (message of messagesValue(); track trackByMessageId($index, message)) {
|
||||
@if (message && message.role === "assistant") {
|
||||
<!-- Assistant message with slot support -->
|
||||
@if (assistantMessageComponent() || assistantMessageTemplate()) {
|
||||
<copilot-slot
|
||||
[slot]="assistantMessageTemplate() || assistantMessageComponent()"
|
||||
[context]="mergeAssistantProps(message)"
|
||||
[defaultComponent]="defaultAssistantComponent"
|
||||
>
|
||||
</copilot-slot>
|
||||
} @else {
|
||||
<copilot-chat-assistant-message
|
||||
[message]="message"
|
||||
[messages]="messagesValue()"
|
||||
[agentId]="agentId()"
|
||||
[isLoading]="isLoadingValue()"
|
||||
[inputClass]="assistantMessageClass()"
|
||||
(thumbsUp)="handleAssistantThumbsUp($event)"
|
||||
(thumbsDown)="handleAssistantThumbsDown($event)"
|
||||
(readAloud)="handleAssistantReadAloud($event)"
|
||||
(regenerate)="handleAssistantRegenerate($event)"
|
||||
>
|
||||
</copilot-chat-assistant-message>
|
||||
}
|
||||
} @else if (message && message.role === "user") {
|
||||
<!-- User message with slot support -->
|
||||
@if (userMessageComponent() || userMessageTemplate()) {
|
||||
<copilot-slot
|
||||
[slot]="userMessageTemplate() || userMessageComponent()"
|
||||
[context]="mergeUserProps(message)"
|
||||
[defaultComponent]="defaultUserComponent"
|
||||
>
|
||||
</copilot-slot>
|
||||
} @else {
|
||||
<copilot-chat-user-message
|
||||
[message]="message"
|
||||
[inputClass]="userMessageClass()"
|
||||
>
|
||||
</copilot-chat-user-message>
|
||||
}
|
||||
} @else if (message && message.role === "reasoning") {
|
||||
<copilot-chat-reasoning-message
|
||||
[message]="asReasoningMessage(message)"
|
||||
[messages]="messagesValue()"
|
||||
[isRunning]="isLoadingValue()"
|
||||
/>
|
||||
} @else if (message && message.role === "activity") {
|
||||
@let activityRender = resolveActivityRender(message);
|
||||
@if (activityRender) {
|
||||
<ng-container
|
||||
[ngComponentOutlet]="activityRender.component"
|
||||
[ngComponentOutletInputs]="activityRender.inputs"
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Cursor - exactly like React's conditional rendering -->
|
||||
@if (showCursorValue()) {
|
||||
@if (cursorComponent() || cursorTemplate()) {
|
||||
<copilot-slot
|
||||
[slot]="cursorTemplate() || cursorComponent()"
|
||||
[context]="{ inputClass: cursorClass() }"
|
||||
[defaultComponent]="defaultCursorComponent"
|
||||
>
|
||||
</copilot-slot>
|
||||
} @else {
|
||||
<copilot-chat-message-view-cursor [inputClass]="cursorClass()">
|
||||
</copilot-chat-message-view-cursor>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class CopilotChatMessageView {
|
||||
// Core inputs matching React props
|
||||
messages = input<Message[]>([]);
|
||||
showCursor = input<boolean>(false);
|
||||
isLoading = input<boolean>(false);
|
||||
inputClass = input<string | undefined>();
|
||||
agentId = input<string | undefined>();
|
||||
|
||||
// Handler availability handled via DI service
|
||||
|
||||
// Assistant message slot inputs
|
||||
assistantMessageComponent = input<Type<any> | undefined>();
|
||||
assistantMessageTemplate = input<TemplateRef<any> | undefined>();
|
||||
assistantMessageClass = input<string | undefined>();
|
||||
|
||||
// User message slot inputs
|
||||
userMessageComponent = input<Type<any> | undefined>();
|
||||
userMessageTemplate = input<TemplateRef<any> | undefined>();
|
||||
userMessageClass = input<string | undefined>();
|
||||
|
||||
// Cursor slot inputs
|
||||
cursorComponent = input<Type<any> | undefined>();
|
||||
cursorTemplate = input<TemplateRef<any> | undefined>();
|
||||
cursorClass = input<string | undefined>();
|
||||
|
||||
// Custom layout template (render prop pattern)
|
||||
@ContentChild("customLayout") customLayoutTemplate?: TemplateRef<any>;
|
||||
|
||||
// Output events (bubbled from child components)
|
||||
assistantMessageThumbsUp = output<{ message: Message }>();
|
||||
assistantMessageThumbsDown = output<{ message: Message }>();
|
||||
assistantMessageReadAloud = output<{ message: Message }>();
|
||||
assistantMessageRegenerate = output<{ message: Message }>();
|
||||
userMessageCopy = output<{ message: Message }>();
|
||||
userMessageEdit = output<{ message: Message }>();
|
||||
|
||||
// Default components for slots
|
||||
protected readonly defaultAssistantComponent = CopilotChatAssistantMessage;
|
||||
protected readonly defaultUserComponent = CopilotChatUserMessage;
|
||||
protected readonly defaultCursorComponent = CopilotChatMessageViewCursor;
|
||||
protected readonly copilotKit = inject(CopilotKit);
|
||||
|
||||
// Derived values from inputs
|
||||
protected messagesValue = computed(() => this.messages());
|
||||
protected showCursorValue = computed(
|
||||
() => this.showCursor() && this.lastMessage()?.role !== "reasoning",
|
||||
);
|
||||
protected isLoadingValue = computed(() => this.isLoading());
|
||||
protected lastMessage = computed(() => {
|
||||
const messages = this.messagesValue();
|
||||
return messages[messages.length - 1];
|
||||
});
|
||||
|
||||
// Computed class matching React: twMerge("flex flex-col", className)
|
||||
computedClass = computed(() =>
|
||||
cn("cpk:flex cpk:flex-col", this.inputClass()),
|
||||
);
|
||||
|
||||
// Layout context for custom templates (render prop pattern)
|
||||
layoutContext = computed(() => ({
|
||||
isLoading: this.isLoadingValue(),
|
||||
messages: this.messagesValue(),
|
||||
showCursor: this.showCursorValue(),
|
||||
messageElements: this.messagesValue().filter(
|
||||
(m) =>
|
||||
m &&
|
||||
(m.role === "assistant" ||
|
||||
m.role === "user" ||
|
||||
m.role === "reasoning" ||
|
||||
m.role === "activity"),
|
||||
),
|
||||
}));
|
||||
|
||||
// Slot resolution computed signals
|
||||
assistantMessageSlot = computed(
|
||||
() => this.assistantMessageComponent() || this.assistantMessageClass(),
|
||||
);
|
||||
|
||||
userMessageSlot = computed(
|
||||
() => this.userMessageComponent() || this.userMessageClass(),
|
||||
);
|
||||
|
||||
cursorSlot = computed(() => this.cursorComponent() || this.cursorClass());
|
||||
|
||||
// Props merging helpers
|
||||
mergeAssistantProps(message: Message) {
|
||||
return {
|
||||
message,
|
||||
messages: this.messagesValue(),
|
||||
isLoading: this.isLoadingValue(),
|
||||
inputClass: this.assistantMessageClass(),
|
||||
};
|
||||
}
|
||||
|
||||
mergeUserProps(message: Message) {
|
||||
return {
|
||||
message,
|
||||
inputClass: this.userMessageClass(),
|
||||
};
|
||||
}
|
||||
|
||||
asReasoningMessage(message: Message): ReasoningMessage {
|
||||
return message as ReasoningMessage;
|
||||
}
|
||||
|
||||
// TrackBy function for performance optimization
|
||||
trackByMessageId(index: number, message: Message): string {
|
||||
return message?.id || `index-${index}`;
|
||||
}
|
||||
|
||||
private pickActivityRenderer(
|
||||
message: ActivityMessage,
|
||||
): RenderActivityMessageConfig | undefined {
|
||||
const agentId = this.agentId();
|
||||
const renderers = this.copilotKit.activityMessageRenderConfigs();
|
||||
const matches = renderers.filter(
|
||||
(renderer) => renderer.activityType === message.activityType,
|
||||
);
|
||||
|
||||
return (
|
||||
matches.find((candidate) => candidate.agentId === agentId) ??
|
||||
matches.find((candidate) => candidate.agentId === undefined) ??
|
||||
renderers.find((candidate) => candidate.activityType === "*")
|
||||
);
|
||||
}
|
||||
|
||||
protected resolveActivityRender(message: ActivityMessage) {
|
||||
const renderer = this.pickActivityRenderer(message);
|
||||
if (!renderer) return undefined;
|
||||
|
||||
const parseResult = renderer.content.safeParse(message.content);
|
||||
if (parseResult.success === false) {
|
||||
console.warn(
|
||||
`Failed to parse content for activity message '${message.activityType}':`,
|
||||
parseResult.error,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const agentId = this.agentId();
|
||||
const agent = agentId ? this.copilotKit.getAgent(agentId) : undefined;
|
||||
return {
|
||||
component: renderer.component,
|
||||
inputs: {
|
||||
activityType: message.activityType,
|
||||
content: parseResult.data,
|
||||
message,
|
||||
agent,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {}
|
||||
|
||||
// Event handlers - just pass them through
|
||||
handleAssistantThumbsUp(event: { message: Message }): void {
|
||||
this.assistantMessageThumbsUp.emit(event);
|
||||
}
|
||||
|
||||
handleAssistantThumbsDown(event: { message: Message }): void {
|
||||
this.assistantMessageThumbsDown.emit(event);
|
||||
}
|
||||
|
||||
handleAssistantReadAloud(event: { message: Message }): void {
|
||||
this.assistantMessageReadAloud.emit(event);
|
||||
}
|
||||
|
||||
handleAssistantRegenerate(event: { message: Message }): void {
|
||||
this.assistantMessageRegenerate.emit(event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Message } from "@ag-ui/client";
|
||||
import { Type, TemplateRef } from "@angular/core";
|
||||
|
||||
// Context interfaces for template slots
|
||||
export interface MessageViewContext {
|
||||
showCursor: boolean;
|
||||
messages: Message[];
|
||||
messageElements: any[]; // Will be populated with rendered elements
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
export interface CursorContext {
|
||||
// Empty for now, can be extended if needed
|
||||
}
|
||||
|
||||
// Component input props interface
|
||||
export interface CopilotChatMessageViewProps {
|
||||
messages?: Message[];
|
||||
showCursor?: boolean;
|
||||
inputClass?: string;
|
||||
|
||||
// Assistant message slots
|
||||
assistantMessageComponent?: Type<any>;
|
||||
assistantMessageTemplate?: TemplateRef<any>;
|
||||
assistantMessageClass?: string;
|
||||
|
||||
// User message slots
|
||||
userMessageComponent?: Type<any>;
|
||||
userMessageTemplate?: TemplateRef<any>;
|
||||
userMessageClass?: string;
|
||||
|
||||
// Cursor slots
|
||||
cursorComponent?: Type<any>;
|
||||
cursorTemplate?: TemplateRef<any>;
|
||||
cursorClass?: string;
|
||||
}
|
||||
|
||||
// Re-export for convenience
|
||||
export type { Message };
|
||||
@@ -0,0 +1,9 @@
|
||||
export function formatReasoningDuration(seconds: number): string {
|
||||
if (seconds < 1) return "a few seconds";
|
||||
if (seconds < 60) return `${Math.round(seconds)} seconds`;
|
||||
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.round(seconds % 60);
|
||||
if (secs === 0) return `${mins} minute${mins > 1 ? "s" : ""}`;
|
||||
return `${mins}m ${secs}s`;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
linkedSignal,
|
||||
signal,
|
||||
untracked,
|
||||
} from "@angular/core";
|
||||
import type { Message, ReasoningMessage } from "@ag-ui/core";
|
||||
import { cn } from "../../utils";
|
||||
import { CopilotChatAssistantMessageRenderer } from "./copilot-chat-assistant-message-renderer";
|
||||
import { formatReasoningDuration } from "./copilot-chat-reasoning-message-utils";
|
||||
|
||||
@Component({
|
||||
selector: "copilot-chat-reasoning-message",
|
||||
imports: [CopilotChatAssistantMessageRenderer],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div
|
||||
[class]="computedClass()"
|
||||
[attr.data-message-id]="message().id"
|
||||
data-testid="copilot-chat-reasoning-message"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
[class]="headerClass()"
|
||||
[attr.aria-expanded]="hasContent() ? open() : null"
|
||||
(click)="toggle()"
|
||||
>
|
||||
<span class="cpk:font-medium">{{ label() }}</span>
|
||||
@if (isStreaming() && !hasContent()) {
|
||||
<span class="cpk:inline-flex cpk:items-center cpk:ml-1">
|
||||
<span
|
||||
class="cpk:w-1.5 cpk:h-1.5 cpk:rounded-full cpk:bg-muted-foreground cpk:animate-pulse"
|
||||
></span>
|
||||
</span>
|
||||
}
|
||||
@if (hasContent()) {
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
[class]="chevronClass()"
|
||||
>
|
||||
<path d="m9 18 6-6-6-6"></path>
|
||||
</svg>
|
||||
}
|
||||
</button>
|
||||
|
||||
@if (hasContent() || isStreaming()) {
|
||||
<div
|
||||
class="cpk:grid cpk:transition-[grid-template-rows] cpk:duration-200 cpk:ease-in-out"
|
||||
[style.grid-template-rows]="open() ? '1fr' : '0fr'"
|
||||
>
|
||||
<div class="cpk:overflow-hidden">
|
||||
<div class="cpk:pb-2 cpk:pt-1">
|
||||
<div class="cpk:text-sm cpk:text-muted-foreground">
|
||||
<copilot-chat-assistant-message-renderer
|
||||
[content]="reasoningContent()"
|
||||
inputClass="cpk:text-sm cpk:text-muted-foreground cpk:leading-relaxed cpk:[&_p]:m-0 cpk:[&_p+p]:mt-2 cpk:[&_strong]:font-semibold cpk:[&_strong]:text-foreground cpk:[&_ul]:my-1 cpk:[&_ol]:my-1 cpk:[&_li]:ml-4"
|
||||
></copilot-chat-assistant-message-renderer>
|
||||
@if (isStreaming() && hasContent()) {
|
||||
<span
|
||||
class="cpk:inline-flex cpk:items-center cpk:ml-1 cpk:align-middle"
|
||||
>
|
||||
<span
|
||||
class="cpk:w-2 cpk:h-2 cpk:rounded-full cpk:bg-muted-foreground cpk:animate-pulse-cursor"
|
||||
></span>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class CopilotChatReasoningMessage {
|
||||
readonly message = input.required<ReasoningMessage>();
|
||||
readonly messages = input<Message[]>([]);
|
||||
readonly isRunning = input<boolean>(false);
|
||||
readonly inputClass = input<string | undefined>();
|
||||
|
||||
private readonly userToggled = signal(false);
|
||||
|
||||
protected readonly isLatest = computed(() => {
|
||||
const messages = this.messages();
|
||||
return messages[messages.length - 1]?.id === this.message().id;
|
||||
});
|
||||
|
||||
protected readonly isStreaming = computed(
|
||||
() => this.isRunning() && this.isLatest(),
|
||||
);
|
||||
|
||||
// Captures the wall-clock start the moment streaming begins and holds it
|
||||
// afterwards. `label` reads this in both branches, keeping the linkedSignal
|
||||
// warm so it recomputes across the streaming transition (no effect needed).
|
||||
private readonly startTime = linkedSignal<boolean, number | undefined>({
|
||||
source: this.isStreaming,
|
||||
computation: (streaming, prev) =>
|
||||
streaming ? (prev?.value ?? Date.now()) : prev?.value,
|
||||
});
|
||||
|
||||
// Opens automatically while streaming. Once the user toggles, their choice is
|
||||
// respected; otherwise the panel auto-collapses when streaming ends. Reads
|
||||
// userToggled untracked so a mid-stream toggle doesn't re-force it open.
|
||||
protected readonly open = linkedSignal<boolean, boolean>({
|
||||
source: this.isStreaming,
|
||||
computation: (streaming, prev) =>
|
||||
streaming
|
||||
? true
|
||||
: untracked(() => this.userToggled())
|
||||
? (prev?.value ?? false)
|
||||
: false,
|
||||
});
|
||||
|
||||
protected readonly hasContent = computed(
|
||||
() => (this.message().content?.length ?? 0) > 0,
|
||||
);
|
||||
|
||||
protected readonly reasoningContent = computed(
|
||||
() => this.message().content ?? "",
|
||||
);
|
||||
|
||||
protected readonly label = computed(() => {
|
||||
const start = this.startTime();
|
||||
if (this.isStreaming()) return "Thinking…";
|
||||
const seconds = start === undefined ? 0 : (Date.now() - start) / 1000;
|
||||
return `Thought for ${formatReasoningDuration(seconds)}`;
|
||||
});
|
||||
|
||||
protected readonly computedClass = computed(() =>
|
||||
cn("cpk:my-1", this.inputClass()),
|
||||
);
|
||||
|
||||
protected readonly headerClass = computed(() =>
|
||||
cn(
|
||||
"cpk:inline-flex cpk:items-center cpk:gap-1 cpk:py-1 cpk:text-sm cpk:text-muted-foreground cpk:transition-colors cpk:select-none",
|
||||
this.hasContent()
|
||||
? "cpk:hover:text-foreground cpk:cursor-pointer"
|
||||
: "cpk:cursor-default",
|
||||
),
|
||||
);
|
||||
|
||||
protected readonly chevronClass = computed(() =>
|
||||
cn(
|
||||
"cpk:size-3.5 cpk:shrink-0 cpk:transition-transform cpk:duration-200",
|
||||
this.open() && "cpk:rotate-90",
|
||||
),
|
||||
);
|
||||
|
||||
protected toggle(): void {
|
||||
if (!this.hasContent()) return;
|
||||
this.userToggled.set(true);
|
||||
this.open.update((value) => !value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
ViewEncapsulation,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
} from "@angular/core";
|
||||
import { cn } from "../../utils";
|
||||
|
||||
const suggestionPillClass = cn(
|
||||
"cpk:group cpk:inline-flex cpk:h-7 cpk:sm:h-8 cpk:items-center cpk:gap-1 cpk:sm:gap-1.5 cpk:rounded-full",
|
||||
"cpk:border cpk:border-border/60 cpk:bg-background cpk:px-2.5 cpk:sm:px-3",
|
||||
"cpk:text-[11px] cpk:sm:text-xs cpk:leading-none cpk:text-foreground cpk:transition-colors",
|
||||
"cpk:cursor-pointer cpk:hover:bg-accent/60 cpk:hover:text-foreground",
|
||||
"cpk:focus-visible:outline-none cpk:focus-visible:ring-2 cpk:focus-visible:ring-ring",
|
||||
"cpk:focus-visible:ring-offset-2 cpk:focus-visible:ring-offset-background",
|
||||
"cpk:disabled:cursor-not-allowed cpk:disabled:text-muted-foreground",
|
||||
"cpk:disabled:hover:bg-background cpk:disabled:hover:text-muted-foreground",
|
||||
"cpk:pointer-events-auto",
|
||||
);
|
||||
|
||||
@Component({
|
||||
selector: "copilot-chat-suggestion-pill",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
host: { "data-copilotkit": "" },
|
||||
template: `
|
||||
<button
|
||||
data-copilotkit
|
||||
data-testid="copilot-suggestion"
|
||||
data-slot="suggestion-pill"
|
||||
type="button"
|
||||
[class]="computedClass()"
|
||||
[disabled]="disabled() || isLoading()"
|
||||
[attr.aria-busy]="isLoading() ? 'true' : null"
|
||||
(click)="handleClick()"
|
||||
>
|
||||
@if (isLoading()) {
|
||||
<span
|
||||
class="cpk:inline-block cpk:size-3 cpk:animate-spin cpk:rounded-full cpk:border cpk:border-current cpk:border-t-transparent cpk:opacity-70"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
}
|
||||
<span class="cpk:whitespace-nowrap cpk:font-medium cpk:leading-none">
|
||||
{{ title() }}
|
||||
</span>
|
||||
</button>
|
||||
`,
|
||||
})
|
||||
export class CopilotChatSuggestionPill {
|
||||
readonly title = input<string>("");
|
||||
readonly disabled = input(false);
|
||||
readonly isLoading = input(false);
|
||||
readonly inputClass = input<string | undefined>();
|
||||
|
||||
readonly clicked = output<void>();
|
||||
|
||||
protected readonly computedClass = computed(() =>
|
||||
cn(suggestionPillClass, this.inputClass()),
|
||||
);
|
||||
|
||||
handleClick(): void {
|
||||
if (this.disabled() || this.isLoading()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.clicked.emit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
ViewEncapsulation,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
} from "@angular/core";
|
||||
import type { Suggestion } from "@copilotkit/core";
|
||||
import { cn } from "../../utils";
|
||||
import { CopilotChatSuggestionPill } from "./copilot-chat-suggestion-pill";
|
||||
|
||||
const suggestionViewClass = cn(
|
||||
"cpk:flex cpk:flex-wrap cpk:items-center cpk:gap-1.5 cpk:sm:gap-2 cpk:pl-0 cpk:pr-4 cpk:@3xl:px-0",
|
||||
"cpk:pointer-events-none",
|
||||
);
|
||||
|
||||
@Component({
|
||||
selector: "copilot-chat-suggestion-view",
|
||||
imports: [CopilotChatSuggestionPill],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
host: { "data-copilotkit": "" },
|
||||
template: `
|
||||
@if (suggestions().length > 0) {
|
||||
<div
|
||||
data-copilotkit
|
||||
data-testid="copilot-suggestions"
|
||||
[class]="computedClass()"
|
||||
>
|
||||
@for (suggestion of suggestions(); track suggestion.message + $index) {
|
||||
<copilot-chat-suggestion-pill
|
||||
[title]="suggestion.title"
|
||||
[inputClass]="suggestion.className"
|
||||
[isLoading]="suggestion.isLoading === true"
|
||||
(clicked)="handleSelect(suggestion, $index)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class CopilotChatSuggestionView {
|
||||
readonly suggestions = input<Suggestion[]>([]);
|
||||
readonly inputClass = input<string | undefined>();
|
||||
|
||||
readonly selectSuggestion = output<{
|
||||
suggestion: Suggestion;
|
||||
index: number;
|
||||
}>();
|
||||
|
||||
protected readonly computedClass = computed(() =>
|
||||
cn(suggestionViewClass, this.inputClass()),
|
||||
);
|
||||
|
||||
handleSelect(suggestion: Suggestion, index: number): void {
|
||||
if (suggestion.isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.selectSuggestion.emit({ suggestion, index });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import {
|
||||
Component,
|
||||
input,
|
||||
output,
|
||||
ElementRef,
|
||||
AfterViewInit,
|
||||
signal,
|
||||
computed,
|
||||
inject,
|
||||
ChangeDetectionStrategy,
|
||||
ViewEncapsulation,
|
||||
} from "@angular/core";
|
||||
import { cn } from "../../utils";
|
||||
import { injectChatLabels } from "../../chat-config";
|
||||
import { injectChatState } from "../../chat-state";
|
||||
|
||||
@Component({
|
||||
selector: "textarea[copilotChatTextarea]",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
host: {
|
||||
"[value]": "computedValue()",
|
||||
"[placeholder]": "placeholder()",
|
||||
"[disabled]": "disabled()",
|
||||
"[class]": "computedClass()",
|
||||
"[style.max-height.px]": "maxHeight()",
|
||||
"[style.overflow]": "'auto'",
|
||||
"[style.resize]": "'none'",
|
||||
"(input)": "onInput($event)",
|
||||
"(keydown)": "onKeyDown($event)",
|
||||
"[attr.rows]": "1",
|
||||
},
|
||||
template: "",
|
||||
styles: [],
|
||||
})
|
||||
export class CopilotChatTextarea implements AfterViewInit {
|
||||
private elementRef = inject(ElementRef<HTMLTextAreaElement>);
|
||||
get textareaRef() {
|
||||
return this.elementRef;
|
||||
}
|
||||
|
||||
inputValue = input<string | undefined>();
|
||||
inputPlaceholder = input<string | undefined>();
|
||||
inputMaxRows = input<number | undefined>();
|
||||
inputAutoFocus = input<boolean | undefined>();
|
||||
inputDisabled = input<boolean | undefined>();
|
||||
inputClass = input<string | undefined>();
|
||||
|
||||
valueChange = output<string>();
|
||||
keyDown = output<KeyboardEvent>();
|
||||
|
||||
readonly chatLabels = injectChatLabels();
|
||||
readonly chatState = injectChatState();
|
||||
|
||||
// Internal signals
|
||||
maxHeight = signal<number>(0);
|
||||
|
||||
// Computed values
|
||||
computedValue = computed(
|
||||
() => this.inputValue() ?? this.chatState.inputValue() ?? "",
|
||||
);
|
||||
placeholder = computed(
|
||||
() => this.inputPlaceholder() || this.chatLabels.chatInputPlaceholder,
|
||||
);
|
||||
disabled = computed(() => this.inputDisabled() ?? false);
|
||||
|
||||
computedClass = computed(() => {
|
||||
const baseClasses = cn(
|
||||
// Layout and sizing
|
||||
"cpk:w-full",
|
||||
// Behavior
|
||||
"cpk:outline-none cpk:resize-none",
|
||||
// Background
|
||||
"cpk:bg-transparent",
|
||||
// Typography
|
||||
"cpk:antialiased cpk:font-regular cpk:leading-relaxed cpk:text-[16px]",
|
||||
// Placeholder styles
|
||||
"cpk:placeholder:text-[#00000077] cpk:dark:placeholder:text-[#fffc]",
|
||||
);
|
||||
return cn(baseClasses, this.inputClass());
|
||||
});
|
||||
|
||||
constructor() {}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.calculateMaxHeight();
|
||||
this.adjustHeight();
|
||||
|
||||
if (this.inputAutoFocus() ?? true) {
|
||||
setTimeout(() => {
|
||||
this.elementRef.nativeElement.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onInput(event: Event): void {
|
||||
const textarea = event.target as HTMLTextAreaElement;
|
||||
const newValue = textarea.value;
|
||||
this.valueChange.emit(newValue);
|
||||
|
||||
this.chatState.changeInput(newValue);
|
||||
|
||||
this.adjustHeight();
|
||||
}
|
||||
|
||||
onKeyDown(event: KeyboardEvent): void {
|
||||
// Check for Enter key without Shift
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
this.keyDown.emit(event);
|
||||
} else {
|
||||
this.keyDown.emit(event);
|
||||
}
|
||||
}
|
||||
|
||||
private calculateMaxHeight(): void {
|
||||
const textarea = this.elementRef.nativeElement;
|
||||
const maxRowsValue = this.inputMaxRows() ?? 5;
|
||||
|
||||
// Save current value
|
||||
const currentValue = textarea.value;
|
||||
|
||||
// Clear content to measure single row height
|
||||
textarea.value = "";
|
||||
textarea.style.height = "auto";
|
||||
|
||||
// Get computed styles to account for padding
|
||||
const computedStyle = window.getComputedStyle(textarea);
|
||||
const paddingTop = parseFloat(computedStyle.paddingTop);
|
||||
const paddingBottom = parseFloat(computedStyle.paddingBottom);
|
||||
|
||||
// Calculate actual content height (without padding)
|
||||
const contentHeight = textarea.scrollHeight - paddingTop - paddingBottom;
|
||||
|
||||
// Calculate max height: content height for maxRows + padding
|
||||
const calculatedMaxHeight =
|
||||
contentHeight * maxRowsValue + paddingTop + paddingBottom;
|
||||
this.maxHeight.set(calculatedMaxHeight);
|
||||
|
||||
// Restore original value
|
||||
textarea.value = currentValue;
|
||||
|
||||
// Adjust height after calculating maxHeight
|
||||
if (currentValue) {
|
||||
this.adjustHeight();
|
||||
}
|
||||
}
|
||||
|
||||
private adjustHeight(): void {
|
||||
const textarea = this.elementRef.nativeElement;
|
||||
const maxHeightValue = this.maxHeight();
|
||||
|
||||
if (maxHeightValue > 0) {
|
||||
textarea.style.height = "auto";
|
||||
textarea.style.height = `${Math.min(textarea.scrollHeight, maxHeightValue)}px`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Public method to focus the textarea
|
||||
*/
|
||||
focus(): void {
|
||||
this.elementRef.nativeElement.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Public method to get current value
|
||||
*/
|
||||
getValue(): string {
|
||||
return this.elementRef.nativeElement.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public method to set value programmatically
|
||||
*/
|
||||
setValue(value: string): void {
|
||||
this.elementRef.nativeElement.value = value;
|
||||
this.valueChange.emit(value);
|
||||
|
||||
this.chatState.changeInput(value);
|
||||
|
||||
setTimeout(() => this.adjustHeight());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Component, ChangeDetectionStrategy, input } from "@angular/core";
|
||||
|
||||
import type { AssistantMessage, Message } from "@ag-ui/core";
|
||||
import { RenderToolCalls } from "../../render-tool-calls";
|
||||
|
||||
@Component({
|
||||
selector: "copilot-chat-tool-calls-view",
|
||||
imports: [RenderToolCalls],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<copilot-render-tool-calls
|
||||
[message]="message()"
|
||||
[messages]="messages()"
|
||||
[agentId]="agentId()"
|
||||
[isLoading]="isLoading()"
|
||||
>
|
||||
</copilot-render-tool-calls>
|
||||
`,
|
||||
})
|
||||
export class CopilotChatToolCallsView {
|
||||
readonly message = input.required<AssistantMessage>();
|
||||
readonly messages = input.required<Message[]>();
|
||||
readonly agentId = input<string | undefined>();
|
||||
readonly isLoading = input<boolean>(false);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
Component,
|
||||
input,
|
||||
computed,
|
||||
ChangeDetectionStrategy,
|
||||
ViewEncapsulation,
|
||||
} from "@angular/core";
|
||||
|
||||
import { cn } from "../../utils";
|
||||
|
||||
@Component({
|
||||
selector: "div[copilotChatToolbar]",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
host: {
|
||||
"[class]": "computedClass()",
|
||||
},
|
||||
template: `
|
||||
<ng-content></ng-content>
|
||||
`,
|
||||
styles: [],
|
||||
})
|
||||
export class CopilotChatToolbar {
|
||||
readonly inputClass = input<string | undefined>();
|
||||
|
||||
readonly computedClass = computed(() => {
|
||||
const baseClasses =
|
||||
"cpk:w-full cpk:h-[60px] cpk:bg-transparent cpk:flex cpk:items-center cpk:justify-between";
|
||||
return cn(baseClasses, this.inputClass());
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import {
|
||||
Component,
|
||||
input,
|
||||
computed,
|
||||
ChangeDetectionStrategy,
|
||||
ViewEncapsulation,
|
||||
} from "@angular/core";
|
||||
|
||||
import { CdkMenuModule } from "@angular/cdk/menu";
|
||||
import { OverlayModule } from "@angular/cdk/overlay";
|
||||
import { LucideAngularModule, Plus, ChevronRight } from "lucide-angular";
|
||||
import type { ToolsMenuItem } from "./copilot-chat-input.types";
|
||||
import { cn } from "../../utils";
|
||||
import { injectChatLabels } from "../../chat-config";
|
||||
import { CopilotTooltip } from "../../directives/tooltip";
|
||||
|
||||
@Component({
|
||||
selector: "copilot-chat-tools-menu",
|
||||
imports: [CdkMenuModule, OverlayModule, LucideAngularModule, CopilotTooltip],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
template: `
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="triggerDisabled()"
|
||||
[class]="buttonClass()"
|
||||
[cdkMenuTriggerFor]="menu"
|
||||
[copilotTooltip]="tooltipLabel()"
|
||||
tooltipPosition="below"
|
||||
>
|
||||
<lucide-angular [img]="PlusIcon" [size]="20"></lucide-angular>
|
||||
</button>
|
||||
|
||||
<ng-template #menu>
|
||||
<div
|
||||
data-copilotkit
|
||||
class="cpk:bg-popover cpk:text-popover-foreground cpk:z-50 cpk:max-h-[var(--radix-dropdown-menu-content-available-height)] cpk:min-w-[8rem] cpk:overflow-x-hidden cpk:overflow-y-auto cpk:rounded-md cpk:border cpk:p-1 cpk:shadow-md"
|
||||
cdkMenu
|
||||
>
|
||||
@for (item of menuItems(); track $index) {
|
||||
@if (item === "-") {
|
||||
<div class="cpk:-mx-1 cpk:my-1 cpk:h-px cpk:bg-border"></div>
|
||||
} @else if (isMenuItem(item)) {
|
||||
@if (item.items && item.items.length > 0) {
|
||||
<!-- Submenu trigger -->
|
||||
<button
|
||||
type="button"
|
||||
class="cpk:relative cpk:flex cpk:w-full cpk:cursor-default cpk:select-none cpk:items-center cpk:gap-2 cpk:rounded-sm cpk:border-none cpk:bg-transparent cpk:px-2 cpk:py-1.5 cpk:text-left cpk:text-sm cpk:outline-hidden cpk:hover:bg-accent cpk:hover:text-accent-foreground cpk:focus:bg-accent cpk:focus:text-accent-foreground"
|
||||
[cdkMenuTriggerFor]="submenu"
|
||||
cdkMenuItem
|
||||
>
|
||||
{{ item.label }}
|
||||
<lucide-angular
|
||||
[img]="ChevronRightIcon"
|
||||
[size]="12"
|
||||
class="cpk:ml-auto"
|
||||
></lucide-angular>
|
||||
</button>
|
||||
|
||||
<!-- Submenu template -->
|
||||
<ng-template #submenu>
|
||||
<div
|
||||
data-copilotkit
|
||||
class="cpk:bg-popover cpk:text-popover-foreground cpk:z-50 cpk:max-h-[var(--radix-dropdown-menu-content-available-height)] cpk:min-w-[8rem] cpk:overflow-x-hidden cpk:overflow-y-auto cpk:rounded-md cpk:border cpk:p-1 cpk:shadow-md"
|
||||
cdkMenu
|
||||
>
|
||||
@for (subItem of item.items; track $index) {
|
||||
@if (subItem === "-") {
|
||||
<div class="cpk:-mx-1 cpk:my-1 cpk:h-px cpk:bg-border"></div>
|
||||
} @else if (isMenuItem(subItem)) {
|
||||
<button
|
||||
type="button"
|
||||
class="cpk:relative cpk:flex cpk:w-full cpk:cursor-default cpk:select-none cpk:items-center cpk:gap-2 cpk:rounded-sm cpk:border-none cpk:bg-transparent cpk:px-2 cpk:py-1.5 cpk:text-left cpk:text-sm cpk:outline-hidden cpk:hover:bg-accent cpk:hover:text-accent-foreground cpk:focus:bg-accent cpk:focus:text-accent-foreground"
|
||||
(click)="handleItemClick(subItem)"
|
||||
cdkMenuItem
|
||||
>
|
||||
{{ subItem.label }}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
} @else {
|
||||
<!-- Regular menu item -->
|
||||
<button
|
||||
type="button"
|
||||
class="cpk:relative cpk:flex cpk:w-full cpk:cursor-default cpk:select-none cpk:items-center cpk:gap-2 cpk:rounded-sm cpk:border-none cpk:bg-transparent cpk:px-2 cpk:py-1.5 cpk:text-left cpk:text-sm cpk:outline-hidden cpk:hover:bg-accent cpk:hover:text-accent-foreground cpk:focus:bg-accent cpk:focus:text-accent-foreground"
|
||||
(click)="handleItemClick(item)"
|
||||
cdkMenuItem
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
/* CDK Overlay styles for positioning */
|
||||
.cdk-overlay-pane {
|
||||
position: absolute;
|
||||
pointer-events: auto;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
/* Ensure menu appears above other content */
|
||||
.cdk-overlay-container {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
/* Menu animation */
|
||||
[cdkMenu] {
|
||||
animation: menuFadeIn 0.15s ease-out;
|
||||
}
|
||||
|
||||
@keyframes menuFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class CopilotChatToolsMenu {
|
||||
readonly PlusIcon = Plus;
|
||||
readonly ChevronRightIcon = ChevronRight;
|
||||
inputToolsMenu = input<(ToolsMenuItem | "-")[] | undefined>();
|
||||
inputDisabled = input<boolean | undefined>();
|
||||
inputClass = input<string | undefined>();
|
||||
inputAddFile = input<(() => void) | undefined>();
|
||||
|
||||
private labels = injectChatLabels();
|
||||
|
||||
// Derive state from inputs
|
||||
toolsMenu = computed(() => this.inputToolsMenu() ?? []);
|
||||
disabled = computed(() => this.inputDisabled() ?? false);
|
||||
customClass = computed(() => this.inputClass());
|
||||
addFile = computed(() => this.inputAddFile());
|
||||
|
||||
menuItems = computed<(ToolsMenuItem | "-")[]>(() => {
|
||||
const items: (ToolsMenuItem | "-")[] = [];
|
||||
const addFile = this.addFile();
|
||||
|
||||
if (addFile) {
|
||||
items.push({
|
||||
label: this.labels.chatInputToolbarAddButtonLabel,
|
||||
action: addFile,
|
||||
});
|
||||
}
|
||||
|
||||
for (const item of this.toolsMenu()) {
|
||||
if (item === "-") {
|
||||
if (items.length === 0 || items[items.length - 1] === "-") {
|
||||
continue;
|
||||
}
|
||||
items.push(item);
|
||||
} else {
|
||||
items.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
while (items.length > 0 && items[items.length - 1] === "-") {
|
||||
items.pop();
|
||||
}
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
hasItems = computed(() => this.menuItems().length > 0);
|
||||
triggerDisabled = computed(() => this.disabled() || !this.hasItems());
|
||||
|
||||
readonly label = this.labels.chatInputToolbarToolsButtonLabel;
|
||||
|
||||
tooltipLabel = computed(() =>
|
||||
this.addFile()
|
||||
? this.labels.chatInputToolbarAddButtonLabel
|
||||
: this.labels.chatInputToolbarToolsButtonLabel,
|
||||
);
|
||||
|
||||
buttonClass = computed(() => {
|
||||
const baseClasses = cn(
|
||||
// Base button styles
|
||||
"cpk:inline-flex cpk:items-center cpk:justify-center cpk:gap-2 cpk:whitespace-nowrap cpk:rounded-full cpk:text-sm cpk:font-medium",
|
||||
"cpk:transition-all cpk:disabled:pointer-events-none cpk:disabled:opacity-50",
|
||||
"cpk:shrink-0 cpk:outline-none",
|
||||
"cpk:focus-visible:ring-[3px]",
|
||||
// chatInputToolbarSecondary variant
|
||||
"cpk:cursor-pointer",
|
||||
"cpk:bg-transparent cpk:text-[#444444]",
|
||||
"cpk:dark:text-white cpk:dark:border-[#404040]",
|
||||
"cpk:transition-colors",
|
||||
"cpk:focus:outline-none",
|
||||
"cpk:hover:bg-[#f8f8f8] cpk:hover:text-[#333333]",
|
||||
"cpk:dark:hover:bg-[#404040] cpk:dark:hover:text-[#FFFFFF]",
|
||||
"cpk:disabled:cursor-not-allowed cpk:disabled:opacity-50",
|
||||
"cpk:disabled:hover:bg-transparent cpk:disabled:hover:text-[#444444]",
|
||||
"cpk:dark:disabled:hover:bg-transparent cpk:dark:disabled:hover:text-[#CCCCCC]",
|
||||
// Size
|
||||
"cpk:h-9 cpk:w-9",
|
||||
);
|
||||
return cn(baseClasses, this.customClass());
|
||||
});
|
||||
|
||||
isMenuItem(item: any): item is ToolsMenuItem {
|
||||
return item && typeof item === "object" && "label" in item;
|
||||
}
|
||||
|
||||
handleItemClick(item: ToolsMenuItem): void {
|
||||
if (item.action) {
|
||||
item.action();
|
||||
}
|
||||
}
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
import {
|
||||
Component,
|
||||
input,
|
||||
output,
|
||||
ChangeDetectionStrategy,
|
||||
ViewEncapsulation,
|
||||
computed,
|
||||
} from "@angular/core";
|
||||
|
||||
import { LucideAngularModule, ChevronLeft, ChevronRight } from "lucide-angular";
|
||||
import { type CopilotChatUserMessageOnSwitchToBranchProps } from "./copilot-chat-user-message.types";
|
||||
import { cn } from "../../utils";
|
||||
import { UserMessage } from "@ag-ui/core";
|
||||
|
||||
@Component({
|
||||
selector: "copilot-chat-user-message-branch-navigation",
|
||||
imports: [LucideAngularModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
template: `
|
||||
@if (showNavigation()) {
|
||||
<div [class]="computedClass()">
|
||||
<button
|
||||
type="button"
|
||||
[class]="buttonClass"
|
||||
[disabled]="!canGoPrev()"
|
||||
(click)="handlePrevious()"
|
||||
>
|
||||
<lucide-angular [img]="ChevronLeftIcon" [size]="20"></lucide-angular>
|
||||
</button>
|
||||
<span
|
||||
class="cpk:text-sm cpk:text-muted-foreground cpk:px-0 cpk:font-medium"
|
||||
>
|
||||
{{ currentBranch() + 1 }}/{{ numberOfBranches() }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
[class]="buttonClass"
|
||||
[disabled]="!canGoNext()"
|
||||
(click)="handleNext()"
|
||||
>
|
||||
<lucide-angular [img]="ChevronRightIcon" [size]="20"></lucide-angular>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class CopilotChatUserMessageBranchNavigation {
|
||||
currentBranch = input<number>(0);
|
||||
numberOfBranches = input<number>(1);
|
||||
message = input<UserMessage>();
|
||||
inputClass = input<string | undefined>();
|
||||
switchToBranch = output<CopilotChatUserMessageOnSwitchToBranchProps>();
|
||||
|
||||
readonly ChevronLeftIcon = ChevronLeft;
|
||||
readonly ChevronRightIcon = ChevronRight;
|
||||
|
||||
readonly buttonClass = cn(
|
||||
// Flex centering
|
||||
"cpk:inline-flex cpk:items-center cpk:justify-center",
|
||||
// Cursor
|
||||
"cpk:cursor-pointer",
|
||||
// Background and text
|
||||
"cpk:p-0 cpk:text-[rgb(93,93,93)] cpk:hover:bg-[#E8E8E8]",
|
||||
// Dark mode
|
||||
"cpk:dark:text-[rgb(243,243,243)] cpk:dark:hover:bg-[#303030]",
|
||||
// Shape and sizing
|
||||
"cpk:h-6 cpk:w-6 cpk:rounded-md",
|
||||
// Interactions
|
||||
"cpk:transition-colors",
|
||||
// Disabled state
|
||||
"cpk:disabled:opacity-50 cpk:disabled:cursor-not-allowed",
|
||||
);
|
||||
|
||||
showNavigation = computed(() => this.numberOfBranches() > 1);
|
||||
|
||||
canGoPrev = computed(() => this.currentBranch() > 0);
|
||||
|
||||
canGoNext = computed(
|
||||
() => this.currentBranch() < this.numberOfBranches() - 1,
|
||||
);
|
||||
|
||||
computedClass = computed(() => {
|
||||
return cn("cpk:flex cpk:items-center cpk:gap-1", this.inputClass());
|
||||
});
|
||||
|
||||
handlePrevious(): void {
|
||||
if (this.canGoPrev()) {
|
||||
const newIndex = this.currentBranch() - 1;
|
||||
this.switchToBranch.emit({
|
||||
branchIndex: newIndex,
|
||||
numberOfBranches: this.numberOfBranches(),
|
||||
message: this.message()!,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleNext(): void {
|
||||
if (this.canGoNext()) {
|
||||
const newIndex = this.currentBranch() + 1;
|
||||
this.switchToBranch.emit({
|
||||
branchIndex: newIndex,
|
||||
numberOfBranches: this.numberOfBranches(),
|
||||
message: this.message()!,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import {
|
||||
Component,
|
||||
input,
|
||||
output,
|
||||
signal,
|
||||
computed,
|
||||
ChangeDetectionStrategy,
|
||||
ViewEncapsulation,
|
||||
} from "@angular/core";
|
||||
|
||||
import { LucideAngularModule, Copy, Check, Edit } from "lucide-angular";
|
||||
import { CopilotTooltip } from "../../directives/tooltip";
|
||||
import { cn } from "../../utils";
|
||||
import { injectChatLabels } from "../../chat-config";
|
||||
import { copyToClipboard } from "@copilotkit/shared";
|
||||
|
||||
// Base toolbar button component
|
||||
@Component({
|
||||
selector: "button[copilotChatUserMessageToolbarButton]",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
template: `
|
||||
<ng-content></ng-content>
|
||||
`,
|
||||
host: {
|
||||
"[class]": "computedClass()",
|
||||
"[attr.disabled]": "disabled() ? true : null",
|
||||
type: "button",
|
||||
"[attr.aria-label]": "title()",
|
||||
},
|
||||
hostDirectives: [
|
||||
{
|
||||
directive: CopilotTooltip,
|
||||
inputs: ["copilotTooltip: title", "tooltipPosition", "tooltipDelay"],
|
||||
},
|
||||
],
|
||||
})
|
||||
export class CopilotChatUserMessageToolbarButton {
|
||||
title = input<string>("");
|
||||
disabled = input<boolean>(false);
|
||||
inputClass = input<string | undefined>();
|
||||
|
||||
computedClass = computed(() => {
|
||||
return cn(
|
||||
// Flex centering
|
||||
"cpk:inline-flex cpk:items-center cpk:justify-center",
|
||||
// Cursor
|
||||
"cpk:cursor-pointer",
|
||||
// Background and text
|
||||
"cpk:p-0 cpk:text-[rgb(93,93,93)] cpk:hover:bg-[#E8E8E8]",
|
||||
// Dark mode
|
||||
"cpk:dark:text-[rgb(243,243,243)] cpk:dark:hover:bg-[#303030]",
|
||||
// Shape and sizing
|
||||
"cpk:h-8 cpk:w-8 cpk:rounded-md",
|
||||
// Interactions
|
||||
"cpk:transition-colors",
|
||||
// Hover states
|
||||
"cpk:hover:text-[rgb(93,93,93)]",
|
||||
"cpk:dark:hover:text-[rgb(243,243,243)]",
|
||||
// Focus states
|
||||
"cpk:focus:outline-none cpk:focus:ring-2 cpk:focus:ring-offset-2",
|
||||
// Disabled state
|
||||
"cpk:disabled:opacity-50 cpk:disabled:cursor-not-allowed",
|
||||
this.inputClass(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Copy button component
|
||||
@Component({
|
||||
selector: "copilot-chat-user-message-copy-button",
|
||||
imports: [LucideAngularModule, CopilotChatUserMessageToolbarButton],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
template: `
|
||||
<button
|
||||
copilotChatUserMessageToolbarButton
|
||||
[title]="title() || labels.userMessageToolbarCopyMessageLabel"
|
||||
[disabled]="disabled()"
|
||||
[inputClass]="inputClass()"
|
||||
(click)="handleCopy()"
|
||||
>
|
||||
@if (copied()) {
|
||||
<lucide-angular [img]="CheckIcon" [size]="18"></lucide-angular>
|
||||
} @else {
|
||||
<lucide-angular [img]="CopyIcon" [size]="18"></lucide-angular>
|
||||
}
|
||||
</button>
|
||||
`,
|
||||
})
|
||||
export class CopilotChatUserMessageCopyButton {
|
||||
readonly title = input<string | undefined>();
|
||||
readonly disabled = input<boolean>(false);
|
||||
readonly inputClass = input<string | undefined>();
|
||||
readonly content = input<string | undefined>();
|
||||
readonly clicked = output<void>();
|
||||
readonly CopyIcon = Copy;
|
||||
readonly CheckIcon = Check;
|
||||
readonly copied = signal(false);
|
||||
readonly labels = injectChatLabels();
|
||||
|
||||
handleCopy(): void {
|
||||
if (!this.content()) return;
|
||||
|
||||
copyToClipboard(this.content()!).then((success) => {
|
||||
if (success) {
|
||||
this.copied.set(true);
|
||||
this.clicked.emit();
|
||||
setTimeout(() => this.copied.set(false), 2000);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Edit button component
|
||||
@Component({
|
||||
selector: "copilot-chat-user-message-edit-button",
|
||||
imports: [LucideAngularModule, CopilotChatUserMessageToolbarButton],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
template: `
|
||||
<button
|
||||
copilotChatUserMessageToolbarButton
|
||||
[title]="title() || labels.userMessageToolbarEditMessageLabel"
|
||||
[disabled]="disabled()"
|
||||
[inputClass]="inputClass()"
|
||||
(click)="handleEdit()"
|
||||
>
|
||||
<lucide-angular [img]="EditIcon" [size]="18"></lucide-angular>
|
||||
</button>
|
||||
`,
|
||||
})
|
||||
export class CopilotChatUserMessageEditButton {
|
||||
title = input<string | undefined>();
|
||||
disabled = input<boolean>(false);
|
||||
inputClass = input<string | undefined>();
|
||||
clicked = output<void>();
|
||||
|
||||
readonly EditIcon = Edit;
|
||||
readonly labels = injectChatLabels();
|
||||
|
||||
handleEdit(): void {
|
||||
if (!this.disabled()) {
|
||||
this.clicked.emit();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
Component,
|
||||
input,
|
||||
ChangeDetectionStrategy,
|
||||
ViewEncapsulation,
|
||||
computed,
|
||||
} from "@angular/core";
|
||||
|
||||
import { cn } from "../../utils";
|
||||
|
||||
@Component({
|
||||
selector: "copilot-chat-user-message-renderer",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
host: {
|
||||
"[class]": "computedClass()",
|
||||
},
|
||||
template: `
|
||||
{{ content() }}
|
||||
`,
|
||||
})
|
||||
export class CopilotChatUserMessageRenderer {
|
||||
readonly content = input<string>("");
|
||||
readonly inputClass = input<string | undefined>();
|
||||
|
||||
readonly computedClass = computed(() => {
|
||||
return cn(
|
||||
"cpk:prose cpk:dark:prose-invert cpk:bg-muted cpk:relative cpk:max-w-[80%] cpk:rounded-[18px] cpk:px-4 cpk:py-1.5 cpk:data-[multiline]:py-3 cpk:inline-block cpk:whitespace-pre-wrap",
|
||||
this.inputClass(),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
Component,
|
||||
input,
|
||||
computed,
|
||||
ChangeDetectionStrategy,
|
||||
ViewEncapsulation,
|
||||
} from "@angular/core";
|
||||
|
||||
import { cn } from "../../utils";
|
||||
|
||||
@Component({
|
||||
selector: "div[copilotChatUserMessageToolbar]",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
template: `
|
||||
<ng-content></ng-content>
|
||||
`,
|
||||
host: {
|
||||
"[class]": "computedClass()",
|
||||
},
|
||||
})
|
||||
export class CopilotChatUserMessageToolbar {
|
||||
readonly inputClass = input<string | undefined>();
|
||||
|
||||
readonly computedClass = computed(() =>
|
||||
cn(
|
||||
"cpk:w-full cpk:bg-transparent cpk:flex cpk:items-center cpk:justify-end cpk:mt-[4px] cpk:invisible cpk:group-hover:visible",
|
||||
this.inputClass(),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { UserMessage } from "@ag-ui/core";
|
||||
import type {
|
||||
AudioInputPart,
|
||||
DocumentInputPart,
|
||||
ImageInputPart,
|
||||
VideoInputPart,
|
||||
} from "@copilotkit/shared";
|
||||
|
||||
export type UserMessageMediaPart =
|
||||
| ImageInputPart
|
||||
| AudioInputPart
|
||||
| VideoInputPart
|
||||
| DocumentInputPart;
|
||||
|
||||
export function flattenUserMessageContent(
|
||||
content?: UserMessage["content"],
|
||||
): string {
|
||||
if (!content) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
|
||||
return content
|
||||
.map((part) => (part.type === "text" ? part.text : ""))
|
||||
.filter((text) => text.length > 0)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
export function getUserMessageMediaParts(
|
||||
content?: UserMessage["content"],
|
||||
): UserMessageMediaPart[] {
|
||||
if (!content || typeof content === "string") {
|
||||
return [];
|
||||
}
|
||||
|
||||
return content.filter(
|
||||
(part): part is UserMessageMediaPart =>
|
||||
part.type === "image" ||
|
||||
part.type === "audio" ||
|
||||
part.type === "video" ||
|
||||
part.type === "document",
|
||||
);
|
||||
}
|
||||
|
||||
export function getUserMessageMediaFilename(
|
||||
part: UserMessageMediaPart,
|
||||
): string | undefined {
|
||||
const meta = part.metadata;
|
||||
if (
|
||||
meta != null &&
|
||||
typeof meta === "object" &&
|
||||
"filename" in meta &&
|
||||
typeof meta.filename === "string"
|
||||
) {
|
||||
return meta.filename;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import {
|
||||
Component,
|
||||
input,
|
||||
output,
|
||||
TemplateRef,
|
||||
ContentChild,
|
||||
computed,
|
||||
Type,
|
||||
ChangeDetectionStrategy,
|
||||
ViewEncapsulation,
|
||||
} from "@angular/core";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { CopilotSlot } from "../../slots/copilot-slot";
|
||||
import {
|
||||
type CopilotChatUserMessageOnEditMessageProps,
|
||||
type CopilotChatUserMessageOnSwitchToBranchProps,
|
||||
type MessageRendererContext,
|
||||
type CopyButtonContext,
|
||||
type EditButtonContext,
|
||||
type BranchNavigationContext,
|
||||
type UserMessageToolbarContext,
|
||||
} from "./copilot-chat-user-message.types";
|
||||
import { CopilotChatUserMessageRenderer } from "./copilot-chat-user-message-renderer";
|
||||
import {
|
||||
CopilotChatUserMessageCopyButton,
|
||||
CopilotChatUserMessageEditButton,
|
||||
} from "./copilot-chat-user-message-buttons";
|
||||
import { CopilotChatUserMessageToolbar } from "./copilot-chat-user-message-toolbar";
|
||||
import { CopilotChatUserMessageBranchNavigation } from "./copilot-chat-user-message-branch-navigation";
|
||||
import { cn } from "../../utils";
|
||||
import { UserMessage } from "@ag-ui/core";
|
||||
import { CopilotChatAttachmentRenderer } from "./copilot-chat-attachment-renderer";
|
||||
import {
|
||||
flattenUserMessageContent,
|
||||
getUserMessageMediaFilename,
|
||||
getUserMessageMediaParts,
|
||||
type UserMessageMediaPart,
|
||||
} from "./copilot-chat-user-message-utils";
|
||||
|
||||
@Component({
|
||||
selector: "copilot-chat-user-message",
|
||||
host: { "data-copilotkit": "" },
|
||||
imports: [
|
||||
CommonModule,
|
||||
CopilotSlot,
|
||||
CopilotChatUserMessageRenderer,
|
||||
CopilotChatUserMessageCopyButton,
|
||||
CopilotChatUserMessageEditButton,
|
||||
CopilotChatUserMessageToolbar,
|
||||
CopilotChatUserMessageBranchNavigation,
|
||||
CopilotChatAttachmentRenderer,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
template: `
|
||||
<div [class]="computedClass()" [attr.data-message-id]="message()?.id">
|
||||
@if (mediaParts().length > 0) {
|
||||
<div
|
||||
class="cpk:flex cpk:flex-row cpk:flex-wrap cpk:justify-end cpk:gap-2 cpk:mb-2"
|
||||
>
|
||||
@for (part of mediaParts(); track $index) {
|
||||
<copilot-chat-attachment-renderer
|
||||
[type]="part.type"
|
||||
[source]="part.source"
|
||||
[filename]="filenameFor(part)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Message Renderer -->
|
||||
@if (messageRendererTemplate || messageRendererComponent()) {
|
||||
<copilot-slot
|
||||
[slot]="messageRendererTemplate || messageRendererComponent()"
|
||||
[context]="messageRendererContext()"
|
||||
[defaultComponent]="CopilotChatUserMessageRenderer"
|
||||
>
|
||||
</copilot-slot>
|
||||
} @else {
|
||||
<copilot-chat-user-message-renderer
|
||||
[content]="flattenedContent()"
|
||||
[inputClass]="messageRendererClass()"
|
||||
>
|
||||
</copilot-chat-user-message-renderer>
|
||||
}
|
||||
|
||||
<!-- Toolbar -->
|
||||
@if (toolbarTemplate || toolbarComponent()) {
|
||||
<copilot-slot
|
||||
[slot]="toolbarTemplate || toolbarComponent()"
|
||||
[context]="toolbarContext()"
|
||||
[defaultComponent]="CopilotChatUserMessageToolbar"
|
||||
>
|
||||
</copilot-slot>
|
||||
} @else {
|
||||
<div copilotChatUserMessageToolbar [inputClass]="toolbarClass()">
|
||||
<div class="cpk:flex cpk:items-center cpk:gap-1 cpk:justify-end">
|
||||
<!-- Additional toolbar items -->
|
||||
@if (additionalToolbarItems()) {
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="additionalToolbarItems() || null"
|
||||
></ng-container>
|
||||
}
|
||||
|
||||
<!-- Copy button -->
|
||||
@if (copyButtonTemplate || copyButtonComponent()) {
|
||||
<copilot-slot
|
||||
[slot]="copyButtonTemplate || copyButtonComponent()"
|
||||
[context]="{ content: flattenedContent() }"
|
||||
[outputs]="copyButtonOutputs"
|
||||
[defaultComponent]="CopilotChatUserMessageCopyButton"
|
||||
>
|
||||
</copilot-slot>
|
||||
} @else {
|
||||
<copilot-chat-user-message-copy-button
|
||||
[content]="flattenedContent()"
|
||||
[inputClass]="copyButtonClass()"
|
||||
(clicked)="handleCopy()"
|
||||
>
|
||||
</copilot-chat-user-message-copy-button>
|
||||
}
|
||||
|
||||
<!-- Edit button -->
|
||||
@if (true) {
|
||||
@if (editButtonTemplate || editButtonComponent()) {
|
||||
<copilot-slot
|
||||
[slot]="editButtonTemplate || editButtonComponent()"
|
||||
[context]="{}"
|
||||
[outputs]="editButtonOutputs"
|
||||
[defaultComponent]="CopilotChatUserMessageEditButton"
|
||||
>
|
||||
</copilot-slot>
|
||||
} @else {
|
||||
<copilot-chat-user-message-edit-button
|
||||
[inputClass]="editButtonClass()"
|
||||
(clicked)="handleEdit()"
|
||||
>
|
||||
</copilot-chat-user-message-edit-button>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Branch navigation -->
|
||||
@if (showBranchNavigation()) {
|
||||
@if (branchNavigationTemplate || branchNavigationComponent()) {
|
||||
<copilot-slot
|
||||
[slot]="branchNavigationTemplate || branchNavigationComponent()"
|
||||
[context]="branchNavigationContext()"
|
||||
[defaultComponent]="CopilotChatUserMessageBranchNavigation"
|
||||
>
|
||||
</copilot-slot>
|
||||
} @else {
|
||||
<copilot-chat-user-message-branch-navigation
|
||||
[currentBranch]="branchIndexValue()"
|
||||
[numberOfBranches]="numberOfBranchesValue()"
|
||||
[message]="message()!"
|
||||
[inputClass]="branchNavigationClass()"
|
||||
(switchToBranch)="handleSwitchToBranch($event)"
|
||||
>
|
||||
</copilot-chat-user-message-branch-navigation>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class CopilotChatUserMessage {
|
||||
// Capture templates from content projection
|
||||
@ContentChild("messageRenderer", { read: TemplateRef })
|
||||
messageRendererTemplate?: TemplateRef<MessageRendererContext>;
|
||||
@ContentChild("toolbar", { read: TemplateRef })
|
||||
toolbarTemplate?: TemplateRef<UserMessageToolbarContext>;
|
||||
@ContentChild("copyButton", { read: TemplateRef })
|
||||
copyButtonTemplate?: TemplateRef<CopyButtonContext>;
|
||||
@ContentChild("editButton", { read: TemplateRef })
|
||||
editButtonTemplate?: TemplateRef<EditButtonContext>;
|
||||
@ContentChild("branchNavigation", { read: TemplateRef })
|
||||
branchNavigationTemplate?: TemplateRef<BranchNavigationContext>;
|
||||
|
||||
// Props for tweaking default components
|
||||
messageRendererClass = input<string | undefined>();
|
||||
toolbarClass = input<string | undefined>();
|
||||
copyButtonClass = input<string | undefined>();
|
||||
editButtonClass = input<string | undefined>();
|
||||
branchNavigationClass = input<string | undefined>();
|
||||
|
||||
// Component inputs for overrides
|
||||
messageRendererComponent = input<Type<any> | undefined>();
|
||||
toolbarComponent = input<Type<any> | undefined>();
|
||||
copyButtonComponent = input<Type<any> | undefined>();
|
||||
editButtonComponent = input<Type<any> | undefined>();
|
||||
branchNavigationComponent = input<Type<any> | undefined>();
|
||||
|
||||
// Regular inputs
|
||||
message = input<UserMessage>();
|
||||
branchIndex = input<number | undefined>();
|
||||
numberOfBranches = input<number | undefined>();
|
||||
additionalToolbarItems = input<TemplateRef<any> | undefined>();
|
||||
inputClass = input<string | undefined>();
|
||||
|
||||
// Output events
|
||||
editMessage = output<CopilotChatUserMessageOnEditMessageProps>();
|
||||
switchToBranch = output<CopilotChatUserMessageOnSwitchToBranchProps>();
|
||||
|
||||
// Derived values
|
||||
branchIndexValue = computed(() => this.branchIndex() ?? 0);
|
||||
numberOfBranchesValue = computed(() => this.numberOfBranches() ?? 1);
|
||||
|
||||
// Default components
|
||||
CopilotChatUserMessageRenderer = CopilotChatUserMessageRenderer;
|
||||
CopilotChatUserMessageToolbar = CopilotChatUserMessageToolbar;
|
||||
CopilotChatUserMessageCopyButton = CopilotChatUserMessageCopyButton;
|
||||
CopilotChatUserMessageEditButton = CopilotChatUserMessageEditButton;
|
||||
CopilotChatUserMessageBranchNavigation =
|
||||
CopilotChatUserMessageBranchNavigation;
|
||||
|
||||
// Computed values
|
||||
showBranchNavigation = computed(() => (this.numberOfBranches() ?? 1) > 1);
|
||||
|
||||
computedClass = computed(() =>
|
||||
cn(
|
||||
"cpk:flex cpk:flex-col cpk:items-end cpk:group cpk:pt-10",
|
||||
this.inputClass(),
|
||||
),
|
||||
);
|
||||
|
||||
// Context for slots (reactive via signals)
|
||||
flattenedContent = computed(() =>
|
||||
flattenUserMessageContent(this.message()?.content),
|
||||
);
|
||||
mediaParts = computed(() =>
|
||||
getUserMessageMediaParts(this.message()?.content),
|
||||
);
|
||||
|
||||
messageRendererContext = computed<MessageRendererContext>(() => ({
|
||||
content: this.flattenedContent(),
|
||||
}));
|
||||
|
||||
// Output maps for slots
|
||||
copyButtonOutputs = { clicked: () => this.handleCopy() };
|
||||
editButtonOutputs = { clicked: () => this.handleEdit() };
|
||||
|
||||
branchNavigationContext = computed<BranchNavigationContext>(() => ({
|
||||
currentBranch: this.branchIndexValue(),
|
||||
numberOfBranches: this.numberOfBranchesValue(),
|
||||
onSwitchToBranch: (props) => this.handleSwitchToBranch(props),
|
||||
message: this.message()!,
|
||||
}));
|
||||
|
||||
toolbarContext = computed<UserMessageToolbarContext>(() => ({
|
||||
children: null, // Will be populated by the toolbar content
|
||||
}));
|
||||
|
||||
handleCopy(): void {
|
||||
// Copy is handled by the button component itself
|
||||
// This is just for any additional logic if needed
|
||||
}
|
||||
|
||||
handleEdit(): void {
|
||||
this.editMessage.emit({ message: this.message()! });
|
||||
}
|
||||
|
||||
handleSwitchToBranch(
|
||||
props: CopilotChatUserMessageOnSwitchToBranchProps,
|
||||
): void {
|
||||
this.switchToBranch.emit(props);
|
||||
}
|
||||
|
||||
filenameFor(part: UserMessageMediaPart): string | undefined {
|
||||
return getUserMessageMediaFilename(part);
|
||||
}
|
||||
constructor() {}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { UserMessage } from "@ag-ui/core";
|
||||
|
||||
export interface CopilotChatUserMessageOnEditMessageProps {
|
||||
message: UserMessage;
|
||||
}
|
||||
|
||||
export interface CopilotChatUserMessageOnSwitchToBranchProps {
|
||||
message: UserMessage;
|
||||
branchIndex: number;
|
||||
numberOfBranches: number;
|
||||
}
|
||||
|
||||
// Context interfaces for slots
|
||||
export interface MessageRendererContext {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface CopyButtonContext {
|
||||
content?: string;
|
||||
copied?: boolean;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
export interface EditButtonContext {
|
||||
// Empty context - click handled via outputs map
|
||||
}
|
||||
|
||||
export interface BranchNavigationContext {
|
||||
currentBranch: number;
|
||||
numberOfBranches: number;
|
||||
onSwitchToBranch?: (
|
||||
props: CopilotChatUserMessageOnSwitchToBranchProps,
|
||||
) => void;
|
||||
message: UserMessage;
|
||||
}
|
||||
|
||||
export interface UserMessageToolbarContext {
|
||||
children?: any;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
Component,
|
||||
input,
|
||||
ChangeDetectionStrategy,
|
||||
ViewEncapsulation,
|
||||
} from "@angular/core";
|
||||
|
||||
import { cn } from "../..//utils";
|
||||
import { injectChatLabels } from "../../chat-config";
|
||||
|
||||
/**
|
||||
* Disclaimer component for CopilotChatView
|
||||
* Shows configurable disclaimer text below the input
|
||||
* Integrates with CopilotChatConfigurationService for labels
|
||||
*/
|
||||
@Component({
|
||||
selector: "copilot-chat-view-disclaimer",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
template: `
|
||||
<div [class]="computedClass">
|
||||
{{ disclaimerText }}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class CopilotChatViewDisclaimer {
|
||||
inputClass = input<string | undefined>();
|
||||
text = input<string | undefined>();
|
||||
|
||||
readonly labels = injectChatLabels();
|
||||
|
||||
// Get disclaimer text from input or configuration
|
||||
get disclaimerText(): string {
|
||||
if (this.text()) {
|
||||
return this.text() as string;
|
||||
}
|
||||
|
||||
return this.labels.chatDisclaimerText;
|
||||
}
|
||||
|
||||
// Computed class matching React exactly
|
||||
get computedClass(): string {
|
||||
return cn(
|
||||
"cpk:text-center cpk:text-xs cpk:text-muted-foreground cpk:py-3 cpk:px-4 cpk:max-w-3xl cpk:mx-auto",
|
||||
this.inputClass(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
Component,
|
||||
input,
|
||||
ChangeDetectionStrategy,
|
||||
ViewEncapsulation,
|
||||
} from "@angular/core";
|
||||
|
||||
import { cn } from "../../utils";
|
||||
|
||||
/**
|
||||
* Feather component for CopilotChatView
|
||||
* Creates a gradient overlay effect between messages and input
|
||||
* Matches React implementation exactly with same Tailwind classes
|
||||
*/
|
||||
@Component({
|
||||
selector: "copilot-chat-view-feather",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
template: `
|
||||
<div [class]="computedClass" [style]="style()"></div>
|
||||
`,
|
||||
})
|
||||
export class CopilotChatViewFeather {
|
||||
inputClass = input<string | undefined>();
|
||||
style = input<{ [key: string]: any } | undefined>();
|
||||
|
||||
// Computed class matching React exactly
|
||||
get computedClass(): string {
|
||||
return cn(
|
||||
// Positioning
|
||||
"cpk:absolute cpk:bottom-0 cpk:left-0 cpk:right-4 cpk:h-24 cpk:pointer-events-none cpk:z-10",
|
||||
// Gradient
|
||||
"cpk:bg-gradient-to-t",
|
||||
// Light mode colors
|
||||
"cpk:from-white cpk:via-white cpk:to-transparent",
|
||||
// Dark mode colors
|
||||
"cpk:dark:from-[rgb(33,33,33)] cpk:dark:via-[rgb(33,33,33)]",
|
||||
// Custom classes
|
||||
this.inputClass(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Injectable, signal } from "@angular/core";
|
||||
|
||||
@Injectable({ providedIn: "root" })
|
||||
export class CopilotChatViewHandlers {
|
||||
// Assistant message handler availability
|
||||
hasAssistantThumbsUpHandler = signal(false);
|
||||
hasAssistantThumbsDownHandler = signal(false);
|
||||
hasAssistantReadAloudHandler = signal(false);
|
||||
hasAssistantRegenerateHandler = signal(false);
|
||||
|
||||
// User message handler availability
|
||||
hasUserCopyHandler = signal(false);
|
||||
hasUserEditHandler = signal(false);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
Component,
|
||||
input,
|
||||
ChangeDetectionStrategy,
|
||||
ViewEncapsulation,
|
||||
forwardRef,
|
||||
ElementRef,
|
||||
inject,
|
||||
} from "@angular/core";
|
||||
|
||||
import { CopilotSlot } from "../../slots/copilot-slot";
|
||||
import { CopilotChatInput } from "./copilot-chat-input";
|
||||
import { CopilotChatViewDisclaimer } from "./copilot-chat-view-disclaimer";
|
||||
import { cn } from "../../utils";
|
||||
import { ChatState } from "../../chat-state";
|
||||
import { CopilotChatAttachmentQueue } from "./copilot-chat-attachment-queue";
|
||||
|
||||
/**
|
||||
* InputContainer component for CopilotChatView
|
||||
* Container for input and disclaimer components
|
||||
* Uses ForwardRef for DOM access
|
||||
*/
|
||||
@Component({
|
||||
selector: "copilot-chat-view-input-container",
|
||||
imports: [CopilotSlot, CopilotChatAttachmentQueue],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
providers: [
|
||||
{
|
||||
provide: ElementRef,
|
||||
useExisting: forwardRef(() => CopilotChatViewInputContainer),
|
||||
},
|
||||
],
|
||||
template: `
|
||||
<div [class]="computedClass">
|
||||
<!-- Input component -->
|
||||
@if ((chatState?.attachments() ?? []).length > 0) {
|
||||
<div class="cpk:max-w-3xl cpk:mx-auto cpk:w-full cpk:pointer-events-auto">
|
||||
<copilot-chat-attachment-queue
|
||||
[attachments]="chatState?.attachments() ?? []"
|
||||
inputClass="cpk:px-4"
|
||||
(removeAttachment)="chatState?.removeAttachment($event)"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="cpk:max-w-3xl cpk:mx-auto cpk:py-0 cpk:px-4 cpk:@3xl:px-0">
|
||||
<copilot-slot
|
||||
[slot]="input()"
|
||||
[context]="{ inputClass: inputClass() }"
|
||||
[defaultComponent]="defaultInputComponent"
|
||||
>
|
||||
</copilot-slot>
|
||||
</div>
|
||||
|
||||
<!-- Disclaimer - always rendered like in React -->
|
||||
<copilot-slot
|
||||
[slot]="disclaimer()"
|
||||
[context]="{ text: disclaimerText(), inputClass: disclaimerClass() }"
|
||||
[defaultComponent]="defaultDisclaimerComponent"
|
||||
>
|
||||
</copilot-slot>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class CopilotChatViewInputContainer extends ElementRef {
|
||||
readonly chatState = inject(ChatState, { optional: true });
|
||||
|
||||
inputContainerClass = input<string | undefined>();
|
||||
|
||||
// Input slot configuration
|
||||
input = input<any | undefined>();
|
||||
inputClass = input<string | undefined>();
|
||||
|
||||
// Disclaimer slot configuration
|
||||
disclaimer = input<any | undefined>();
|
||||
disclaimerText = input<string | undefined>();
|
||||
disclaimerClass = input<string | undefined>();
|
||||
|
||||
// Default components
|
||||
protected readonly defaultInputComponent = CopilotChatInput;
|
||||
protected readonly defaultDisclaimerComponent = CopilotChatViewDisclaimer;
|
||||
|
||||
constructor(elementRef: ElementRef) {
|
||||
super(elementRef.nativeElement);
|
||||
}
|
||||
|
||||
get computedClass(): string {
|
||||
return cn(
|
||||
"cpk:absolute cpk:bottom-6 cpk:left-0 cpk:right-0 cpk:z-20",
|
||||
this.inputContainerClass(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
Component,
|
||||
input,
|
||||
output,
|
||||
ChangeDetectionStrategy,
|
||||
ViewEncapsulation,
|
||||
} from "@angular/core";
|
||||
|
||||
import { LucideAngularModule, ChevronDown } from "lucide-angular";
|
||||
import { cn } from "../../utils";
|
||||
|
||||
/**
|
||||
* ScrollToBottomButton component for CopilotChatView
|
||||
* Matches React implementation exactly with same Tailwind classes
|
||||
*/
|
||||
@Component({
|
||||
selector: "copilot-chat-view-scroll-to-bottom-button",
|
||||
imports: [LucideAngularModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
template: `
|
||||
<button
|
||||
type="button"
|
||||
[class]="computedClass"
|
||||
[disabled]="disabled()"
|
||||
(click)="handleClick()"
|
||||
>
|
||||
<lucide-angular
|
||||
[img]="ChevronDown"
|
||||
class="cpk:w-4 cpk:h-4 cpk:text-gray-600 cpk:dark:text-white"
|
||||
>
|
||||
</lucide-angular>
|
||||
</button>
|
||||
`,
|
||||
})
|
||||
export class CopilotChatViewScrollToBottomButton {
|
||||
inputClass = input<string | undefined>();
|
||||
disabled = input<boolean>(false);
|
||||
// Support function-style click handler via slot context
|
||||
onClick = input<(() => void) | undefined>();
|
||||
|
||||
// Simple, idiomatic Angular output
|
||||
clicked = output<void>();
|
||||
|
||||
// Icon reference
|
||||
protected readonly ChevronDown = ChevronDown;
|
||||
|
||||
// Computed class matching React exactly
|
||||
get computedClass(): string {
|
||||
return cn(
|
||||
// Base button styles
|
||||
"cpk:rounded-full cpk:w-10 cpk:h-10 cpk:p-0",
|
||||
// Background colors
|
||||
"cpk:bg-white cpk:dark:bg-gray-900",
|
||||
// Border and shadow
|
||||
"cpk:shadow-lg cpk:border cpk:border-gray-200 cpk:dark:border-gray-700",
|
||||
// Hover states
|
||||
"cpk:hover:bg-gray-50 cpk:dark:hover:bg-gray-800",
|
||||
// Layout
|
||||
"cpk:flex cpk:items-center cpk:justify-center cpk:cursor-pointer",
|
||||
// Transition
|
||||
"cpk:transition-colors",
|
||||
// Focus states
|
||||
"cpk:focus:outline-none cpk:focus-visible:ring-2 cpk:focus-visible:ring-offset-2",
|
||||
// Custom classes
|
||||
this.inputClass(),
|
||||
);
|
||||
}
|
||||
|
||||
handleClick(): void {
|
||||
if (!this.disabled()) {
|
||||
// Call input handler if provided (slot-style)
|
||||
if (this.onClick()) {
|
||||
this.onClick()!();
|
||||
}
|
||||
this.clicked.emit();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<ng-template #scrollContent>
|
||||
<div [style.padding-bottom.px]="paddingBottom()">
|
||||
<div class="cpk:max-w-3xl cpk:mx-auto">
|
||||
@if (messageView()) {
|
||||
<copilot-slot
|
||||
[slot]="messageView()"
|
||||
[context]="messageViewContext()"
|
||||
[defaultComponent]="defaultMessageViewComponent"
|
||||
>
|
||||
</copilot-slot>
|
||||
} @else {
|
||||
<copilot-chat-message-view
|
||||
[messages]="messages()"
|
||||
[agentId]="agentId()"
|
||||
[inputClass]="messageViewClass()"
|
||||
[showCursor]="showCursor()"
|
||||
(assistantMessageThumbsUp)="assistantMessageThumbsUp.emit($event)"
|
||||
(assistantMessageThumbsDown)="assistantMessageThumbsDown.emit($event)"
|
||||
(assistantMessageReadAloud)="assistantMessageReadAloud.emit($event)"
|
||||
(assistantMessageRegenerate)="assistantMessageRegenerate.emit($event)"
|
||||
(userMessageCopy)="userMessageCopy.emit($event)"
|
||||
(userMessageEdit)="userMessageEdit.emit($event)"
|
||||
>
|
||||
</copilot-chat-message-view>
|
||||
} @if (hasSuggestions()) {
|
||||
<div class="cpk:pl-0 cpk:pr-4 cpk:@3xl:px-0 cpk:mt-4">
|
||||
<copilot-chat-suggestion-view
|
||||
[suggestions]="chatState?.suggestions?.() ?? []"
|
||||
inputClass="cpk:mb-3 cpk:lg:ml-4 cpk:lg:mr-4 cpk:ml-0 cpk:mr-0"
|
||||
(selectSuggestion)="
|
||||
chatState?.selectSuggestion($event.suggestion, $event.index)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
@if (!hasMounted()) {
|
||||
<!-- SSR/Initial render without stick-to-bottom -->
|
||||
<div
|
||||
class="cpk:h-full cpk:max-h-full cpk:flex cpk:flex-col cpk:flex-1 cpk:min-h-0 cpk:overflow-y-auto cpk:overflow-x-hidden"
|
||||
>
|
||||
<div class="cpk:px-4 cpk:@3xl:px-0">
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div
|
||||
class="cpk:h-[calc(100vh-9rem)] cpk:flex cpk:flex-col cpk:min-h-0 cpk:relative"
|
||||
>
|
||||
@if (autoScroll()) {
|
||||
<!-- Auto-scroll mode with StickToBottom directive -->
|
||||
<div
|
||||
#scrollContainer
|
||||
cdkScrollable
|
||||
copilotStickToBottom
|
||||
[enabled]="autoScroll()"
|
||||
[threshold]="10"
|
||||
[debounceMs]="0"
|
||||
[initialBehavior]="'smooth'"
|
||||
[resizeBehavior]="'smooth'"
|
||||
(isAtBottomChange)="onIsAtBottomChange($event)"
|
||||
[class]="computedClass()"
|
||||
class="cpk:flex-1 cpk:min-h-0 cpk:overflow-y-auto cpk:overflow-x-hidden"
|
||||
>
|
||||
<!-- Scrollable content wrapper -->
|
||||
<div class="cpk:px-4 cpk:@3xl:px-0">
|
||||
<!-- Content with padding-bottom matching React -->
|
||||
<ng-container [ngTemplateOutlet]="scrollContent"></ng-container>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<!-- Manual scroll mode -->
|
||||
<div
|
||||
#scrollContainer
|
||||
cdkScrollable
|
||||
[class]="computedClass()"
|
||||
class="cpk:flex-1 cpk:min-h-0 cpk:overflow-y-auto cpk:overflow-x-hidden"
|
||||
>
|
||||
<div #contentContainer class="cpk:px-4 cpk:@3xl:px-0">
|
||||
<!-- Content with padding-bottom matching React -->
|
||||
<ng-container [ngTemplateOutlet]="scrollContent"></ng-container>
|
||||
</div>
|
||||
</div>
|
||||
} @if ((autoScroll() ? !isAtBottom() : showScrollButton()) && !isResizing()) {
|
||||
<div
|
||||
class="cpk:absolute cpk:inset-x-0 cpk:flex cpk:justify-center cpk:z-30"
|
||||
[style.bottom.px]="inputContainerHeight() + 16"
|
||||
>
|
||||
<copilot-slot
|
||||
[slot]="scrollToBottomButton()"
|
||||
[context]="
|
||||
autoScroll()
|
||||
? scrollToBottomFromStickContext()
|
||||
: scrollToBottomContext()
|
||||
"
|
||||
[defaultComponent]="defaultScrollToBottomButtonComponent"
|
||||
[outputs]="
|
||||
autoScroll() ? scrollToBottomFromStickOutputs : scrollToBottomOutputs
|
||||
"
|
||||
>
|
||||
</copilot-slot>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import {
|
||||
Component,
|
||||
input,
|
||||
output,
|
||||
ViewChild,
|
||||
ElementRef,
|
||||
ChangeDetectionStrategy,
|
||||
ViewEncapsulation,
|
||||
signal,
|
||||
computed,
|
||||
OnInit,
|
||||
AfterViewInit,
|
||||
OnDestroy,
|
||||
inject,
|
||||
PLATFORM_ID,
|
||||
ChangeDetectorRef,
|
||||
} from "@angular/core";
|
||||
import { isPlatformBrowser, NgTemplateOutlet } from "@angular/common";
|
||||
import { ScrollingModule } from "@angular/cdk/scrolling";
|
||||
import { CopilotSlot } from "../../slots/copilot-slot";
|
||||
import { CopilotChatMessageView } from "./copilot-chat-message-view";
|
||||
import { CopilotChatViewScrollToBottomButton } from "./copilot-chat-view-scroll-to-bottom-button";
|
||||
import { CopilotChatSuggestionView } from "./copilot-chat-suggestion-view";
|
||||
import { StickToBottom } from "../../directives/stick-to-bottom";
|
||||
import { ScrollPosition } from "../../scroll-position";
|
||||
import { ChatState } from "../../chat-state";
|
||||
import { Message } from "@ag-ui/client";
|
||||
import { cn } from "../../utils";
|
||||
import { Subject } from "rxjs";
|
||||
import { takeUntil } from "rxjs/operators";
|
||||
|
||||
/**
|
||||
* ScrollView component for CopilotChatView
|
||||
* Handles auto-scrolling and scroll position management
|
||||
*/
|
||||
@Component({
|
||||
selector: "copilot-chat-view-scroll-view",
|
||||
host: { class: "cpk:block cpk:flex-1 cpk:min-h-0" },
|
||||
imports: [
|
||||
ScrollingModule,
|
||||
NgTemplateOutlet,
|
||||
CopilotSlot,
|
||||
CopilotChatMessageView,
|
||||
CopilotChatSuggestionView,
|
||||
StickToBottom,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
providers: [ScrollPosition],
|
||||
templateUrl: "./copilot-chat-view-scroll-view.html",
|
||||
})
|
||||
export class CopilotChatViewScrollView
|
||||
implements OnInit, AfterViewInit, OnDestroy
|
||||
{
|
||||
private cdr = inject(ChangeDetectorRef);
|
||||
protected readonly chatState = inject(ChatState, { optional: true });
|
||||
|
||||
autoScroll = input<boolean>(true);
|
||||
|
||||
inputContainerHeight = input<number>(0);
|
||||
|
||||
isResizing = input<boolean>(false);
|
||||
inputClass = input<string | undefined>();
|
||||
messages = input<Message[]>([]);
|
||||
agentId = input<string | undefined>();
|
||||
messageView = input<any | undefined>();
|
||||
messageViewClass = input<string | undefined>();
|
||||
showCursor = input<boolean>(false);
|
||||
|
||||
// Handler availability flags removed in favor of DI service
|
||||
|
||||
// Slot inputs
|
||||
scrollToBottomButton = input<any | undefined>();
|
||||
scrollToBottomButtonClass = input<string | undefined>();
|
||||
|
||||
// Output events (bubbled from message view)
|
||||
assistantMessageThumbsUp = output<{ message: Message }>();
|
||||
assistantMessageThumbsDown = output<{ message: Message }>();
|
||||
assistantMessageReadAloud = output<{ message: Message }>();
|
||||
assistantMessageRegenerate = output<{ message: Message }>();
|
||||
userMessageCopy = output<{ message: Message }>();
|
||||
userMessageEdit = output<{ message: Message }>();
|
||||
|
||||
// ViewChild references
|
||||
@ViewChild("scrollContainer", { read: ElementRef })
|
||||
scrollContainer?: ElementRef<HTMLElement>;
|
||||
@ViewChild("contentContainer", { read: ElementRef })
|
||||
contentContainer?: ElementRef<HTMLElement>;
|
||||
@ViewChild(StickToBottom) stickToBottomDirective?: StickToBottom;
|
||||
|
||||
// Default components
|
||||
protected readonly defaultMessageViewComponent = CopilotChatMessageView;
|
||||
protected readonly defaultScrollToBottomButtonComponent =
|
||||
CopilotChatViewScrollToBottomButton;
|
||||
|
||||
// Signals
|
||||
protected hasMounted = signal(false);
|
||||
protected showScrollButton = signal(false);
|
||||
protected isAtBottom = signal(true);
|
||||
protected hasSuggestions = computed(
|
||||
() =>
|
||||
!this.showCursor() && (this.chatState?.suggestions?.() ?? []).length > 0,
|
||||
);
|
||||
protected paddingBottom = computed(
|
||||
() => this.inputContainerHeight() + (this.hasSuggestions() ? 4 : 32),
|
||||
);
|
||||
|
||||
// Computed class
|
||||
protected computedClass = computed(() => cn(this.inputClass()));
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
private platformId = inject(PLATFORM_ID);
|
||||
private scrollPositionService = inject(ScrollPosition);
|
||||
|
||||
// No mirroring of inputs; derive directly via computed()
|
||||
|
||||
ngOnInit(): void {
|
||||
// Check if we're in the browser
|
||||
if (isPlatformBrowser(this.platformId)) {
|
||||
// Set mounted after a tick to allow for hydration
|
||||
setTimeout(() => {
|
||||
this.hasMounted.set(true);
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
if (!this.autoScroll()) {
|
||||
// Wait for the view to be fully rendered after hasMounted is set
|
||||
setTimeout(() => {
|
||||
if (this.scrollContainer) {
|
||||
// Check initial scroll position
|
||||
const initialState = this.scrollPositionService.getScrollState(
|
||||
this.scrollContainer.nativeElement,
|
||||
10,
|
||||
);
|
||||
this.showScrollButton.set(!initialState.isAtBottom);
|
||||
|
||||
// Monitor scroll position for manual mode
|
||||
this.scrollPositionService
|
||||
.monitorScrollPosition(this.scrollContainer, 10)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((state) => {
|
||||
this.showScrollButton.set(!state.isAtBottom);
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle isAtBottom change from StickToBottom directive
|
||||
*/
|
||||
onIsAtBottomChange(isAtBottom: boolean): void {
|
||||
this.isAtBottom.set(isAtBottom);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll to bottom for manual mode
|
||||
*/
|
||||
scrollToBottom(): void {
|
||||
if (this.scrollContainer) {
|
||||
this.scrollPositionService.scrollToBottom(this.scrollContainer, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll to bottom for stick-to-bottom mode
|
||||
*/
|
||||
scrollToBottomFromStick(): void {
|
||||
if (this.stickToBottomDirective) {
|
||||
this.stickToBottomDirective.scrollToBottom("smooth");
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
// Output maps for slots
|
||||
scrollToBottomOutputs = { clicked: () => this.scrollToBottom() };
|
||||
scrollToBottomFromStickOutputs = {
|
||||
clicked: () => this.scrollToBottomFromStick(),
|
||||
};
|
||||
|
||||
// Context methods for templates
|
||||
messageViewContext(): any {
|
||||
return {
|
||||
messages: this.messages(),
|
||||
agentId: this.agentId(),
|
||||
inputClass: this.messageViewClass(),
|
||||
showCursor: this.showCursor(),
|
||||
};
|
||||
}
|
||||
|
||||
scrollToBottomContext(): any {
|
||||
return {
|
||||
inputClass: this.scrollToBottomButtonClass(),
|
||||
onClick: () => this.scrollToBottom(),
|
||||
};
|
||||
}
|
||||
|
||||
scrollToBottomFromStickContext(): any {
|
||||
return {
|
||||
inputClass: this.scrollToBottomButtonClass(),
|
||||
onClick: () => this.scrollToBottomFromStick(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
import {
|
||||
Component,
|
||||
ContentChild,
|
||||
TemplateRef,
|
||||
Type,
|
||||
ViewChild,
|
||||
ElementRef,
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
ViewEncapsulation,
|
||||
computed,
|
||||
signal,
|
||||
OnInit,
|
||||
OnChanges,
|
||||
OnDestroy,
|
||||
AfterViewInit,
|
||||
input,
|
||||
output,
|
||||
inject,
|
||||
} from "@angular/core";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { CopilotSlot } from "../../slots/copilot-slot";
|
||||
import { CopilotChatViewScrollView } from "./copilot-chat-view-scroll-view";
|
||||
import { CopilotChatViewScrollToBottomButton } from "./copilot-chat-view-scroll-to-bottom-button";
|
||||
import { CopilotChatViewFeather } from "./copilot-chat-view-feather";
|
||||
import { CopilotChatViewInputContainer } from "./copilot-chat-view-input-container";
|
||||
import { CopilotChatViewDisclaimer } from "./copilot-chat-view-disclaimer";
|
||||
import { CopilotChatInput } from "./copilot-chat-input";
|
||||
import { CopilotChatAttachmentQueue } from "./copilot-chat-attachment-queue";
|
||||
import { CopilotChatSuggestionView } from "./copilot-chat-suggestion-view";
|
||||
import { Message } from "@ag-ui/client";
|
||||
import { cn } from "../../utils";
|
||||
import { ResizeObserverService } from "../../resize-observer";
|
||||
import { CopilotChatViewHandlers } from "./copilot-chat-view-handlers";
|
||||
import { Subject } from "rxjs";
|
||||
import { takeUntil } from "rxjs/operators";
|
||||
import { ChatState } from "../../chat-state";
|
||||
import { LucideAngularModule, Upload } from "lucide-angular";
|
||||
import { injectChatLabels } from "../../chat-config";
|
||||
|
||||
/**
|
||||
* CopilotChatView component - Angular port of the React component.
|
||||
* A complete chat interface with message feed and input components.
|
||||
*
|
||||
* @example
|
||||
* ```html
|
||||
* <copilot-chat-view
|
||||
* [messages]="messages"
|
||||
* [autoScroll]="true"
|
||||
* [messageViewProps]="{ assistantMessage: { onThumbsUp: handleThumbsUp } }">
|
||||
* </copilot-chat-view>
|
||||
* ```
|
||||
*/
|
||||
@Component({
|
||||
selector: "copilot-chat-view",
|
||||
imports: [
|
||||
CommonModule,
|
||||
CopilotSlot,
|
||||
CopilotChatViewScrollView,
|
||||
CopilotChatAttachmentQueue,
|
||||
CopilotChatSuggestionView,
|
||||
LucideAngularModule,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
providers: [ResizeObserverService, CopilotChatViewHandlers],
|
||||
host: { class: "cpk:block cpk:h-full cpk:min-h-0" },
|
||||
template: `
|
||||
<!-- Custom layout template support (render prop pattern) -->
|
||||
@if (customLayoutTemplate) {
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="customLayoutTemplate"
|
||||
[ngTemplateOutletContext]="layoutContext()"
|
||||
></ng-container>
|
||||
} @else if (shouldShowWelcomeScreen()) {
|
||||
<div
|
||||
[class]="computedClass()"
|
||||
(dragover)="chatState?.handleDragOver($event)"
|
||||
(dragleave)="chatState?.handleDragLeave($event)"
|
||||
(drop)="chatState?.handleDrop($event)"
|
||||
>
|
||||
@if (chatState?.dragOver?.()) {
|
||||
<div [class]="dropOverlayClass()">
|
||||
<div
|
||||
class="cpk:flex cpk:flex-col cpk:items-center cpk:gap-2 cpk:text-primary/70"
|
||||
>
|
||||
<lucide-angular [img]="UploadIcon" [size]="32"></lucide-angular>
|
||||
<span class="cpk:text-sm cpk:font-medium">Drop files here</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div
|
||||
data-testid="copilot-welcome-screen"
|
||||
class="cpk:flex-1 cpk:flex cpk:flex-col cpk:items-center cpk:justify-center cpk:px-4"
|
||||
>
|
||||
<div
|
||||
class="cpk:w-full cpk:max-w-3xl cpk:flex cpk:flex-col cpk:items-center"
|
||||
>
|
||||
<div class="cpk:mb-6">
|
||||
<h1
|
||||
class="cpk:text-xl cpk:sm:text-2xl cpk:font-medium cpk:text-foreground cpk:text-center"
|
||||
>
|
||||
{{ labels.welcomeMessageText }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="cpk:w-full">
|
||||
@if ((chatState?.attachments?.() ?? []).length > 0) {
|
||||
<copilot-chat-attachment-queue
|
||||
[attachments]="chatState?.attachments?.() ?? []"
|
||||
inputClass="cpk:mb-2"
|
||||
(removeAttachment)="chatState?.removeAttachment($event)"
|
||||
/>
|
||||
}
|
||||
|
||||
<copilot-slot
|
||||
[slot]="inputSlot()"
|
||||
[context]="{ inputClass: undefined }"
|
||||
[defaultComponent]="defaultInputComponent"
|
||||
>
|
||||
</copilot-slot>
|
||||
|
||||
<copilot-slot
|
||||
[slot]="disclaimerSlot()"
|
||||
[context]="{
|
||||
text: disclaimerTextSignal(),
|
||||
inputClass: disclaimerClassSignal(),
|
||||
}"
|
||||
[defaultComponent]="defaultDisclaimerComponent"
|
||||
>
|
||||
</copilot-slot>
|
||||
</div>
|
||||
|
||||
@if ((chatState?.suggestions?.() ?? []).length > 0) {
|
||||
<div class="cpk:mt-4 cpk:flex cpk:justify-center">
|
||||
<copilot-chat-suggestion-view
|
||||
[suggestions]="chatState?.suggestions?.() ?? []"
|
||||
(selectSuggestion)="
|
||||
chatState?.selectSuggestion($event.suggestion, $event.index)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<!-- Default layout - exact React DOM structure -->
|
||||
<div
|
||||
[class]="computedClass()"
|
||||
(dragover)="chatState?.handleDragOver($event)"
|
||||
(dragleave)="chatState?.handleDragLeave($event)"
|
||||
(drop)="chatState?.handleDrop($event)"
|
||||
>
|
||||
@if (chatState?.dragOver?.()) {
|
||||
<div [class]="dropOverlayClass()">
|
||||
<div
|
||||
class="cpk:flex cpk:flex-col cpk:items-center cpk:gap-2 cpk:text-primary/70"
|
||||
>
|
||||
<lucide-angular [img]="UploadIcon" [size]="32"></lucide-angular>
|
||||
<span class="cpk:text-sm cpk:font-medium">Drop files here</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- ScrollView -->
|
||||
<copilot-chat-view-scroll-view
|
||||
[autoScroll]="autoScrollSignal()"
|
||||
[inputContainerHeight]="inputContainerHeight()"
|
||||
[isResizing]="isResizing()"
|
||||
[messages]="messagesValue()"
|
||||
[agentId]="agentId()"
|
||||
[messageView]="messageViewSlot()"
|
||||
[messageViewClass]="messageViewClass()"
|
||||
[scrollToBottomButton]="scrollToBottomButtonSlot()"
|
||||
[scrollToBottomButtonClass]="scrollToBottomButtonClass()"
|
||||
[showCursor]="showCursorSignal()"
|
||||
(assistantMessageThumbsUp)="assistantMessageThumbsUp.emit($event)"
|
||||
(assistantMessageThumbsDown)="assistantMessageThumbsDown.emit($event)"
|
||||
(assistantMessageReadAloud)="assistantMessageReadAloud.emit($event)"
|
||||
(assistantMessageRegenerate)="assistantMessageRegenerate.emit($event)"
|
||||
(userMessageCopy)="userMessageCopy.emit($event)"
|
||||
(userMessageEdit)="userMessageEdit.emit($event)"
|
||||
>
|
||||
</copilot-chat-view-scroll-view>
|
||||
|
||||
<!-- Feather effect -->
|
||||
<copilot-slot
|
||||
[slot]="featherSlot()"
|
||||
[context]="{ inputClass: featherClass }"
|
||||
[defaultComponent]="defaultFeatherComponent"
|
||||
>
|
||||
</copilot-slot>
|
||||
|
||||
<!-- Input container -->
|
||||
<copilot-slot
|
||||
#inputContainerSlotRef
|
||||
[slot]="inputContainerSlot()"
|
||||
[context]="inputContainerContext()"
|
||||
[defaultComponent]="defaultInputContainerComponent"
|
||||
>
|
||||
</copilot-slot>
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class CopilotChatView
|
||||
implements OnInit, OnChanges, AfterViewInit, OnDestroy
|
||||
{
|
||||
// Core inputs matching React props
|
||||
protected readonly chatState = inject(ChatState, { optional: true });
|
||||
protected readonly UploadIcon = Upload;
|
||||
messages = input<Message[]>([]);
|
||||
agentId = input<string | undefined>();
|
||||
autoScroll = input<boolean>(true);
|
||||
showCursor = input<boolean>(false);
|
||||
hasExplicitThreadId = input<boolean>(false);
|
||||
|
||||
// MessageView slot inputs
|
||||
messageViewComponent = input<Type<any> | undefined>(undefined);
|
||||
messageViewTemplate = input<TemplateRef<any> | undefined>(undefined);
|
||||
messageViewClass = input<string | undefined>(undefined);
|
||||
|
||||
// ScrollView slot inputs
|
||||
scrollViewComponent = input<Type<any> | undefined>(undefined);
|
||||
scrollViewTemplate = input<TemplateRef<any> | undefined>(undefined);
|
||||
scrollViewClass = input<string | undefined>(undefined);
|
||||
|
||||
// ScrollToBottomButton slot inputs
|
||||
scrollToBottomButtonComponent = input<Type<any> | undefined>(undefined);
|
||||
scrollToBottomButtonTemplate = input<TemplateRef<any> | undefined>(undefined);
|
||||
scrollToBottomButtonClass = input<string | undefined>(undefined);
|
||||
|
||||
// Input slot inputs
|
||||
inputComponent = input<Type<any> | undefined>(undefined);
|
||||
inputTemplate = input<TemplateRef<any> | undefined>(undefined);
|
||||
|
||||
// InputContainer slot inputs
|
||||
inputContainerComponent = input<Type<any> | undefined>(undefined);
|
||||
inputContainerTemplate = input<TemplateRef<any> | undefined>(undefined);
|
||||
inputContainerClass = input<string | undefined>(undefined);
|
||||
|
||||
// Feather slot inputs
|
||||
featherComponent = input<Type<any> | undefined>(undefined);
|
||||
featherTemplate = input<TemplateRef<any> | undefined>(undefined);
|
||||
featherClass = input<string | undefined>(undefined);
|
||||
|
||||
// Disclaimer slot inputs
|
||||
disclaimerComponent = input<Type<any> | undefined>(undefined);
|
||||
disclaimerTemplate = input<TemplateRef<any> | undefined>(undefined);
|
||||
disclaimerClass = input<string | undefined>(undefined);
|
||||
disclaimerText = input<string | undefined>(undefined);
|
||||
|
||||
// Custom layout template (render prop pattern)
|
||||
@ContentChild("customLayout") customLayoutTemplate?: TemplateRef<any>;
|
||||
|
||||
// Named template slots for deep customization
|
||||
@ContentChild("sendButton") sendButtonTemplate?: TemplateRef<any>;
|
||||
@ContentChild("toolbar") toolbarTemplate?: TemplateRef<any>;
|
||||
@ContentChild("textArea") textAreaTemplate?: TemplateRef<any>;
|
||||
@ContentChild("audioRecorder") audioRecorderTemplate?: TemplateRef<any>;
|
||||
@ContentChild("assistantMessageMarkdownRenderer")
|
||||
assistantMessageMarkdownRendererTemplate?: TemplateRef<any>;
|
||||
@ContentChild("thumbsUpButton") thumbsUpButtonTemplate?: TemplateRef<any>;
|
||||
@ContentChild("thumbsDownButton") thumbsDownButtonTemplate?: TemplateRef<any>;
|
||||
@ContentChild("readAloudButton") readAloudButtonTemplate?: TemplateRef<any>;
|
||||
@ContentChild("regenerateButton") regenerateButtonTemplate?: TemplateRef<any>;
|
||||
|
||||
// Output events for assistant message actions (bubbled from child components)
|
||||
assistantMessageThumbsUp = output<{ message: Message }>();
|
||||
assistantMessageThumbsDown = output<{ message: Message }>();
|
||||
assistantMessageReadAloud = output<{ message: Message }>();
|
||||
assistantMessageRegenerate = output<{ message: Message }>();
|
||||
|
||||
// Output events for user message actions (if applicable)
|
||||
userMessageCopy = output<{ message: Message }>();
|
||||
userMessageEdit = output<{ message: Message }>();
|
||||
|
||||
// ViewChild references
|
||||
@ViewChild("inputContainerSlotRef", { read: ElementRef })
|
||||
inputContainerSlotRef?: ElementRef;
|
||||
|
||||
// Default components for slots
|
||||
protected readonly defaultScrollViewComponent = CopilotChatViewScrollView;
|
||||
protected readonly defaultScrollToBottomButtonComponent =
|
||||
CopilotChatViewScrollToBottomButton;
|
||||
protected readonly defaultInputContainerComponent =
|
||||
CopilotChatViewInputContainer;
|
||||
protected readonly defaultInputComponent = CopilotChatInput;
|
||||
protected readonly defaultFeatherComponent = CopilotChatViewFeather;
|
||||
protected readonly defaultDisclaimerComponent = CopilotChatViewDisclaimer;
|
||||
protected readonly labels = injectChatLabels();
|
||||
|
||||
// Signals for reactive state
|
||||
protected messagesValue = computed(() => this.messages());
|
||||
protected autoScrollSignal = computed(() => this.autoScroll());
|
||||
protected showCursorSignal = computed(() => this.showCursor());
|
||||
protected disclaimerTextSignal = computed(() => this.disclaimerText());
|
||||
protected disclaimerClassSignal = computed(() => this.disclaimerClass());
|
||||
protected inputContainerHeight = signal<number>(0);
|
||||
protected isResizing = signal<boolean>(false);
|
||||
protected shouldShowWelcomeScreen = computed(
|
||||
() => this.messagesValue().length === 0 && !this.hasExplicitThreadId(),
|
||||
);
|
||||
|
||||
// Computed signals
|
||||
protected computedClass = computed(() =>
|
||||
cn(
|
||||
"copilotKitChat cpk:@container cpk:relative cpk:h-full cpk:flex cpk:flex-col",
|
||||
),
|
||||
);
|
||||
protected dropOverlayClass = computed(() =>
|
||||
cn(
|
||||
"cpk:absolute cpk:inset-0 cpk:z-50 cpk:pointer-events-none",
|
||||
"cpk:flex cpk:items-center cpk:justify-center",
|
||||
"cpk:bg-primary/5 cpk:backdrop-blur-[2px]",
|
||||
"cpk:border-2 cpk:border-dashed cpk:border-primary/40 cpk:rounded-lg cpk:m-2",
|
||||
),
|
||||
);
|
||||
|
||||
// Slot resolution computed signals
|
||||
protected messageViewSlot = computed(
|
||||
() => this.messageViewTemplate() || this.messageViewComponent(),
|
||||
);
|
||||
|
||||
protected scrollViewSlot = computed(
|
||||
() => this.scrollViewTemplate() || this.scrollViewComponent(),
|
||||
);
|
||||
|
||||
protected scrollToBottomButtonSlot = computed(
|
||||
() =>
|
||||
this.scrollToBottomButtonTemplate() ||
|
||||
this.scrollToBottomButtonComponent(),
|
||||
);
|
||||
|
||||
protected inputSlot = computed(
|
||||
() => this.inputTemplate() || this.inputComponent(),
|
||||
);
|
||||
|
||||
protected inputContainerSlot = computed(
|
||||
() => this.inputContainerTemplate() || this.inputContainerComponent(),
|
||||
);
|
||||
|
||||
protected featherSlot = computed(
|
||||
() => this.featherTemplate() || this.featherComponent(),
|
||||
);
|
||||
|
||||
protected disclaimerSlot = computed(
|
||||
() => this.disclaimerTemplate() || this.disclaimerComponent(),
|
||||
);
|
||||
|
||||
// Context objects for slots
|
||||
protected scrollViewContext = computed(() => ({
|
||||
autoScroll: this.autoScrollSignal(),
|
||||
scrollToBottomButton: this.scrollToBottomButtonSlot(),
|
||||
scrollToBottomButtonClass: this.scrollToBottomButtonClass(),
|
||||
inputContainerHeight: this.inputContainerHeight(),
|
||||
isResizing: this.isResizing(),
|
||||
messages: this.messagesValue(),
|
||||
agentId: this.agentId(),
|
||||
messageView: this.messageViewSlot(),
|
||||
messageViewClass: this.messageViewClass(),
|
||||
}));
|
||||
|
||||
// Removed scrollViewPropsComputed - no longer needed
|
||||
|
||||
protected inputContainerContext = computed(() => ({
|
||||
input: this.inputSlot(),
|
||||
disclaimer: this.disclaimerSlot(),
|
||||
disclaimerText: this.disclaimerTextSignal(),
|
||||
disclaimerClass: this.disclaimerClassSignal(),
|
||||
inputContainerClass: this.inputContainerClass(),
|
||||
}));
|
||||
|
||||
// Removed inputContainerPropsComputed - no longer needed
|
||||
|
||||
// Layout context for custom templates (render prop pattern)
|
||||
protected layoutContext = computed(() => ({
|
||||
messageView: this.messageViewSlot(),
|
||||
input: this.inputSlot(),
|
||||
scrollView: this.scrollViewSlot(),
|
||||
scrollToBottomButton: this.scrollToBottomButtonSlot(),
|
||||
feather: this.featherSlot(),
|
||||
inputContainer: this.inputContainerSlot(),
|
||||
disclaimer: this.disclaimerSlot(),
|
||||
}));
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
private resizeTimeoutRef?: number;
|
||||
|
||||
constructor(
|
||||
private resizeObserverService: ResizeObserverService,
|
||||
private cdr: ChangeDetectorRef,
|
||||
private handlers: CopilotChatViewHandlers,
|
||||
) {
|
||||
// Clear any pending resize timeout when toggling isResizing, without signal writes here
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
// Initialize handler availability in the view-scoped service
|
||||
// OutputEmitterRef doesn't expose 'observed'; default to true to enable UI affordances
|
||||
this.handlers.hasAssistantThumbsUpHandler.set(true);
|
||||
this.handlers.hasAssistantThumbsDownHandler.set(true);
|
||||
this.handlers.hasAssistantReadAloudHandler.set(true);
|
||||
this.handlers.hasAssistantRegenerateHandler.set(true);
|
||||
this.handlers.hasUserCopyHandler.set(true);
|
||||
this.handlers.hasUserEditHandler.set(true);
|
||||
}
|
||||
|
||||
ngOnChanges(): void {
|
||||
// Keep handler availability in sync (assume available)
|
||||
this.handlers.hasAssistantThumbsUpHandler.set(true);
|
||||
this.handlers.hasAssistantThumbsDownHandler.set(true);
|
||||
this.handlers.hasAssistantReadAloudHandler.set(true);
|
||||
this.handlers.hasAssistantRegenerateHandler.set(true);
|
||||
this.handlers.hasUserCopyHandler.set(true);
|
||||
this.handlers.hasUserEditHandler.set(true);
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
// Don't set a default height - measure it dynamically
|
||||
|
||||
// Set up input container height monitoring
|
||||
const measureAndObserve = () => {
|
||||
if (
|
||||
!this.inputContainerSlotRef ||
|
||||
!this.inputContainerSlotRef.nativeElement
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The slot ref points to the copilot-slot element
|
||||
// We need to find the actual input container component inside it
|
||||
const slotElement = this.inputContainerSlotRef.nativeElement;
|
||||
const componentElement = slotElement.querySelector(
|
||||
"copilot-chat-view-input-container",
|
||||
);
|
||||
|
||||
if (!componentElement) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Look for the absolute positioned div that contains the input
|
||||
let innerDiv = componentElement.querySelector(
|
||||
"div.absolute",
|
||||
) as HTMLElement;
|
||||
|
||||
// If not found by class, try first child
|
||||
if (!innerDiv) {
|
||||
innerDiv = componentElement.firstElementChild as HTMLElement;
|
||||
}
|
||||
|
||||
if (!innerDiv) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Measure the actual height
|
||||
const measuredHeight = innerDiv.offsetHeight;
|
||||
|
||||
if (measuredHeight === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Success! Set the initial height
|
||||
this.inputContainerHeight.set(measuredHeight);
|
||||
this.cdr.detectChanges();
|
||||
|
||||
// Create an ElementRef wrapper for ResizeObserver
|
||||
const innerDivRef = new ElementRef(innerDiv);
|
||||
|
||||
// Set up ResizeObserver to track changes
|
||||
this.resizeObserverService
|
||||
.observeElement(innerDivRef, 0, 250)
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe((state) => {
|
||||
const newHeight = state.height;
|
||||
|
||||
if (newHeight !== this.inputContainerHeight() && newHeight > 0) {
|
||||
this.inputContainerHeight.set(newHeight);
|
||||
this.isResizing.set(true);
|
||||
this.cdr.detectChanges();
|
||||
|
||||
// Clear existing timeout
|
||||
if (this.resizeTimeoutRef) {
|
||||
clearTimeout(this.resizeTimeoutRef);
|
||||
}
|
||||
|
||||
// Set isResizing to false after a short delay
|
||||
this.resizeTimeoutRef = window.setTimeout(() => {
|
||||
this.isResizing.set(false);
|
||||
this.resizeTimeoutRef = undefined;
|
||||
this.cdr.detectChanges();
|
||||
}, 250);
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Try to measure immediately
|
||||
if (!measureAndObserve()) {
|
||||
// If failed, retry with increasing delays
|
||||
let attempts = 0;
|
||||
const maxAttempts = 10;
|
||||
|
||||
const retry = () => {
|
||||
attempts++;
|
||||
if (measureAndObserve()) {
|
||||
// Successfully measured
|
||||
} else if (attempts < maxAttempts) {
|
||||
// Exponential backoff: 50ms, 100ms, 200ms, 400ms, etc.
|
||||
const delay = 50 * Math.pow(2, Math.min(attempts - 1, 4));
|
||||
setTimeout(retry, delay);
|
||||
} else {
|
||||
// Failed to measure after max attempts
|
||||
}
|
||||
};
|
||||
|
||||
// Start retry with first delay
|
||||
setTimeout(retry, 50);
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.resizeTimeoutRef) {
|
||||
clearTimeout(this.resizeTimeoutRef);
|
||||
}
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Type, TemplateRef } from "@angular/core";
|
||||
import { Message } from "@ag-ui/client";
|
||||
|
||||
/**
|
||||
* Props for CopilotChatView component
|
||||
*/
|
||||
export interface CopilotChatViewProps {
|
||||
messages?: Message[];
|
||||
autoScroll?: boolean;
|
||||
|
||||
// Slot configurations
|
||||
messageViewComponent?: Type<any>;
|
||||
messageViewTemplate?: TemplateRef<any>;
|
||||
messageViewClass?: string;
|
||||
|
||||
scrollViewComponent?: Type<any>;
|
||||
scrollViewTemplate?: TemplateRef<any>;
|
||||
scrollViewClass?: string;
|
||||
|
||||
scrollToBottomButtonComponent?: Type<any>;
|
||||
scrollToBottomButtonTemplate?: TemplateRef<any>;
|
||||
scrollToBottomButtonClass?: string;
|
||||
|
||||
inputComponent?: Type<any>;
|
||||
inputTemplate?: TemplateRef<any>;
|
||||
|
||||
inputContainerComponent?: Type<any>;
|
||||
inputContainerTemplate?: TemplateRef<any>;
|
||||
inputContainerClass?: string;
|
||||
|
||||
featherComponent?: Type<any>;
|
||||
featherTemplate?: TemplateRef<any>;
|
||||
featherClass?: string;
|
||||
|
||||
disclaimerComponent?: Type<any>;
|
||||
disclaimerTemplate?: TemplateRef<any>;
|
||||
disclaimerClass?: string;
|
||||
disclaimerText?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context for custom layout template
|
||||
*/
|
||||
export interface CopilotChatViewLayoutContext {
|
||||
messageView: any;
|
||||
input: any;
|
||||
scrollView: any;
|
||||
scrollToBottomButton: any;
|
||||
feather: any;
|
||||
inputContainer: any;
|
||||
disclaimer: any;
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
import {
|
||||
Component,
|
||||
input,
|
||||
ChangeDetectionStrategy,
|
||||
ViewEncapsulation,
|
||||
signal,
|
||||
effect,
|
||||
ChangeDetectorRef,
|
||||
Injector,
|
||||
Type,
|
||||
computed,
|
||||
inject,
|
||||
viewChild,
|
||||
DestroyRef,
|
||||
} from "@angular/core";
|
||||
|
||||
import { CopilotChatView } from "./copilot-chat-view";
|
||||
import { CopilotChatAttachmentsDirective } from "./copilot-chat-attachments.directive";
|
||||
|
||||
import {
|
||||
DEFAULT_AGENT_ID,
|
||||
randomUUID,
|
||||
type AttachmentsConfig,
|
||||
} from "@copilotkit/shared";
|
||||
import {
|
||||
Message,
|
||||
AbstractAgent,
|
||||
HttpAgent,
|
||||
AGUIConnectNotImplementedError,
|
||||
} from "@ag-ui/client";
|
||||
import type { Suggestion } from "@copilotkit/core";
|
||||
import { injectAgentStore } from "../../agent";
|
||||
import { CopilotKit } from "../../copilotkit";
|
||||
import { ChatState } from "../../chat-state";
|
||||
import { transcribeAudio } from "../../transcription";
|
||||
import { COPILOT_CHAT_CONFIGURATION } from "../../chat-configuration";
|
||||
import { connectActiveThread } from "../../active-thread-connector";
|
||||
|
||||
/**
|
||||
* CopilotChat component - Angular equivalent of React's <CopilotChat>
|
||||
* Provides a complete chat interface that wires an agent to the chat view
|
||||
*
|
||||
* @example
|
||||
* ```html
|
||||
* <copilot-chat [agentId]="'default'" [threadId]="'abc123'"></copilot-chat>
|
||||
* ```
|
||||
*/
|
||||
@Component({
|
||||
selector: "copilot-chat",
|
||||
imports: [CopilotChatView, CopilotChatAttachmentsDirective],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
host: { "data-copilotkit": "", class: "cpk:block cpk:h-full cpk:min-h-0" },
|
||||
template: `
|
||||
<div
|
||||
style="display: contents"
|
||||
copilotChatAttachments
|
||||
[config]="attachmentsConfig()"
|
||||
>
|
||||
<copilot-chat-view
|
||||
[messages]="messages()"
|
||||
[agentId]="resolvedAgentId()"
|
||||
[autoScroll]="true"
|
||||
[messageViewClass]="'cpk:w-full'"
|
||||
[showCursor]="showCursor()"
|
||||
[inputComponent]="inputComponent()"
|
||||
[hasExplicitThreadId]="hasExplicitThreadId()"
|
||||
>
|
||||
</copilot-chat-view>
|
||||
</div>
|
||||
`,
|
||||
providers: [
|
||||
{
|
||||
provide: ChatState,
|
||||
useExisting: CopilotChat,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class CopilotChat extends ChatState {
|
||||
private readonly attachmentsDirective = viewChild(
|
||||
CopilotChatAttachmentsDirective,
|
||||
);
|
||||
|
||||
readonly inputValue = signal<string>("");
|
||||
readonly agentId = input<string | undefined>();
|
||||
readonly threadId = input<string | undefined>();
|
||||
readonly inputComponent = input<Type<any> | undefined>();
|
||||
readonly attachmentsConfig = input<AttachmentsConfig | undefined>(undefined, {
|
||||
alias: "attachments",
|
||||
});
|
||||
/**
|
||||
* Ambient chat configuration, when a {@link provideCopilotChatConfiguration}
|
||||
* provider is in scope. Absent (`null`) for standalone
|
||||
* `<copilot-chat [threadId]>` usage, which has no provider — resolved via the
|
||||
* optional inject so the component does not throw without one.
|
||||
*/
|
||||
private readonly config = inject(COPILOT_CHAT_CONFIGURATION, {
|
||||
optional: true,
|
||||
});
|
||||
protected readonly resolvedAgentId = computed(
|
||||
() => this.agentId() ?? this.config?.agentId() ?? DEFAULT_AGENT_ID,
|
||||
);
|
||||
readonly agentStore = injectAgentStore(this.resolvedAgentId);
|
||||
private readonly copilotKit = inject(CopilotKit);
|
||||
readonly cdr = inject(ChangeDetectorRef);
|
||||
readonly injector = inject(Injector);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
protected messages = computed(() => this.agentStore().messages());
|
||||
protected isRunning = computed(() => this.agentStore().isRunning());
|
||||
protected readonly hasExplicitThreadId =
|
||||
this.config?.hasExplicitThreadId ??
|
||||
computed(() => Boolean(this.threadId()));
|
||||
protected readonly agentRef = computed(() => this.agentStore().agent);
|
||||
protected readonly resolvedThreadId = computed(
|
||||
() => this.threadId() || this.generatedThreadId,
|
||||
);
|
||||
override readonly attachmentsEnabled = computed(
|
||||
() => this.attachmentsConfig()?.enabled ?? false,
|
||||
);
|
||||
override readonly attachmentsUploading = computed(() =>
|
||||
this.attachments().some((attachment) => attachment.status === "uploading"),
|
||||
);
|
||||
protected showCursor = signal<boolean>(false);
|
||||
|
||||
private generatedThreadId: string = randomUUID();
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
const suggestionsSubscription = this.copilotKit.core.subscribe({
|
||||
onAgentsChanged: () => {
|
||||
const agentId = this.resolvedAgentId();
|
||||
this.syncSuggestionsFromCore(agentId);
|
||||
if (this.copilotKit.core.getAgent(agentId)) {
|
||||
this.copilotKit.reloadSuggestions(agentId);
|
||||
}
|
||||
},
|
||||
onSuggestionsChanged: ({ agentId, suggestions }) => {
|
||||
if (agentId !== this.resolvedAgentId()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.suggestions.set(suggestions);
|
||||
this.suggestionsLoading.set(
|
||||
this.copilotKit.core.getSuggestions(agentId).isLoading,
|
||||
);
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
onSuggestionsStartedLoading: ({ agentId }) => {
|
||||
if (agentId !== this.resolvedAgentId()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.suggestionsLoading.set(true);
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
onSuggestionsFinishedLoading: ({ agentId }) => {
|
||||
if (agentId !== this.resolvedAgentId()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.syncSuggestionsFromCore(agentId);
|
||||
},
|
||||
onSuggestionsConfigChanged: () => {
|
||||
const agentId = this.resolvedAgentId();
|
||||
this.syncSuggestionsFromCore(agentId);
|
||||
this.copilotKit.reloadSuggestions(agentId);
|
||||
},
|
||||
});
|
||||
|
||||
this.destroyRef.onDestroy(() => suggestionsSubscription.unsubscribe());
|
||||
|
||||
effect(() => {
|
||||
const agentId = this.resolvedAgentId();
|
||||
this.syncSuggestionsFromCore(agentId);
|
||||
this.copilotKit.reloadSuggestions(agentId);
|
||||
});
|
||||
|
||||
if (this.config) {
|
||||
// A set `[threadId]` input seeds the ambient config so the input
|
||||
// actually drives the active thread (not just the welcome flag). When
|
||||
// the config is controlled by a host-provided `threadId` option,
|
||||
// `setActiveThreadId` no-ops — so a controlled config wins over the
|
||||
// input, matching React's prop-precedence. When `[threadId]` is unset,
|
||||
// the effect does nothing and the config drives as before.
|
||||
effect(() => {
|
||||
const inputThreadId = this.threadId();
|
||||
if (inputThreadId) {
|
||||
this.config!.setActiveThreadId(inputThreadId, { explicit: true });
|
||||
}
|
||||
});
|
||||
|
||||
// Ambient configuration drives the active thread: the connector pins
|
||||
// `agent.threadId` from the config's resolved thread signal and connects
|
||||
// on explicit switches (or clears messages on a fresh thread).
|
||||
//
|
||||
// The connector receives the RAW `core.connectAgent` and owns the loading
|
||||
// cursor + abort + detach lifecycle, matching the standalone
|
||||
// `connectToAgent` path exactly: cursor on at connect start, off when the
|
||||
// connect settles (guarded against a superseded run). Connect errors still
|
||||
// surface via the AgentStore's run/error subscription, not here.
|
||||
connectActiveThread(
|
||||
this.config,
|
||||
this.agentStore,
|
||||
(params) => this.copilotKit.core.connectAgent(params),
|
||||
{
|
||||
onConnectStart: () => {
|
||||
this.showCursor.set(true);
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
onConnectSettle: () => {
|
||||
this.showCursor.set(false);
|
||||
this.cdr.markForCheck();
|
||||
},
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// Standalone `<copilot-chat [threadId]>` usage with no configuration
|
||||
// provider: the active thread is input-driven exactly as before.
|
||||
effect((onCleanup) => {
|
||||
const agent = this.agentRef();
|
||||
const threadId = this.resolvedThreadId();
|
||||
|
||||
agent.threadId = threadId;
|
||||
|
||||
if (!this.hasExplicitThreadId()) return;
|
||||
|
||||
let detached = false;
|
||||
const abortController = new AbortController();
|
||||
if (agent instanceof HttpAgent) {
|
||||
agent.abortController = abortController;
|
||||
}
|
||||
|
||||
void this.connectToAgent(agent, () => detached);
|
||||
|
||||
onCleanup(() => {
|
||||
detached = true;
|
||||
abortController.abort();
|
||||
void agent.detachActiveRun().catch(() => {});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async connectToAgent(
|
||||
agent: AbstractAgent,
|
||||
isDetached: () => boolean,
|
||||
): Promise<void> {
|
||||
this.showCursor.set(true);
|
||||
this.cdr.markForCheck();
|
||||
|
||||
try {
|
||||
await this.copilotKit.core.connectAgent({ agent });
|
||||
} catch (error) {
|
||||
if (isDetached()) return;
|
||||
if (!(error instanceof AGUIConnectNotImplementedError)) {
|
||||
console.error("Failed to connect to agent:", error);
|
||||
}
|
||||
} finally {
|
||||
if (!isDetached()) {
|
||||
this.showCursor.set(false);
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async submitInput(value: string): Promise<void> {
|
||||
const agent = this.agentStore().agent;
|
||||
if (!agent || !value.trim()) return;
|
||||
|
||||
if (this.attachmentsUploading()) {
|
||||
console.error("[CopilotKit] Cannot send while attachments are uploading");
|
||||
return;
|
||||
}
|
||||
|
||||
const attachments = this.attachmentsDirective();
|
||||
const readyAttachments = attachments?.consume() ?? [];
|
||||
const userMessage: Message =
|
||||
readyAttachments.length > 0
|
||||
? ({
|
||||
id: randomUUID(),
|
||||
role: "user",
|
||||
content: attachments!.buildContent(value, readyAttachments),
|
||||
} as Message)
|
||||
: {
|
||||
id: randomUUID(),
|
||||
role: "user",
|
||||
content: value,
|
||||
};
|
||||
agent.addMessage(userMessage);
|
||||
|
||||
// Clear the input
|
||||
this.inputValue.set("");
|
||||
|
||||
// Show cursor while processing
|
||||
this.showCursor.set(true);
|
||||
this.cdr.markForCheck();
|
||||
|
||||
// Run the agent via core so tools (and context, forwardedProps) are included
|
||||
try {
|
||||
await this.copilotKit.core.runAgent({ agent });
|
||||
} catch (error) {
|
||||
console.error("Agent run error:", error);
|
||||
} finally {
|
||||
this.showCursor.set(false);
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
}
|
||||
|
||||
async selectSuggestion(
|
||||
suggestion: Suggestion,
|
||||
_index: number,
|
||||
): Promise<void> {
|
||||
const agent = this.agentStore().agent;
|
||||
const message = suggestion.message.trim();
|
||||
if (!agent || !message || suggestion.isLoading) return;
|
||||
|
||||
agent.addMessage({
|
||||
id: randomUUID(),
|
||||
role: "user",
|
||||
content: message,
|
||||
});
|
||||
|
||||
this.inputValue.set("");
|
||||
this.showCursor.set(true);
|
||||
this.cdr.markForCheck();
|
||||
|
||||
try {
|
||||
await this.copilotKit.core.runAgent({ agent });
|
||||
} catch (error) {
|
||||
console.error("Agent run error:", error);
|
||||
} finally {
|
||||
this.showCursor.set(false);
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
}
|
||||
|
||||
changeInput(value: string): void {
|
||||
this.inputValue.set(value);
|
||||
}
|
||||
|
||||
override async finishTranscription(audioBlob: Blob): Promise<void> {
|
||||
this.isTranscribing.set(true);
|
||||
this.cdr.markForCheck();
|
||||
|
||||
try {
|
||||
const result = await transcribeAudio(this.copilotKit.core, audioBlob);
|
||||
const text = result.text?.trim();
|
||||
if (text) {
|
||||
const previous = this.inputValue().trim();
|
||||
this.inputValue.set(previous ? `${previous} ${text}` : text);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[CopilotKit] Transcription failed:", error);
|
||||
} finally {
|
||||
this.isTranscribing.set(false);
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
}
|
||||
|
||||
private syncSuggestionsFromCore(agentId: string): void {
|
||||
const result = this.copilotKit.core.getSuggestions(agentId);
|
||||
this.suggestions.set(result.suggestions);
|
||||
this.suggestionsLoading.set(result.isLoading);
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
addFile(): void {
|
||||
this.attachmentsDirective()?.openFilePicker();
|
||||
}
|
||||
|
||||
removeAttachment(id: string): void {
|
||||
this.attachmentsDirective()?.removeAttachment(id);
|
||||
}
|
||||
|
||||
handleDragOver(event: DragEvent): void {
|
||||
this.attachmentsDirective()?.onDragOver(event);
|
||||
}
|
||||
|
||||
handleDragLeave(event: DragEvent): void {
|
||||
this.attachmentsDirective()?.onDragLeave(event);
|
||||
}
|
||||
|
||||
handleDrop(event: DragEvent): void {
|
||||
void this.attachmentsDirective()?.onDrop(event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,633 @@
|
||||
import {
|
||||
CUSTOM_ELEMENTS_SCHEMA,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
DestroyRef,
|
||||
Directive,
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
Output,
|
||||
TemplateRef,
|
||||
computed,
|
||||
contentChild,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
viewChild,
|
||||
} from "@angular/core";
|
||||
import { NgTemplateOutlet } from "@angular/common";
|
||||
import {
|
||||
DEFAULT_AGENT_ID,
|
||||
createLicenseContextValue,
|
||||
} from "@copilotkit/shared";
|
||||
import { defineCopilotKitThreadsDrawer } from "@copilotkit/web-components/threads-drawer";
|
||||
import type {
|
||||
ArchiveDetail,
|
||||
DeleteDetail,
|
||||
DrawerThread,
|
||||
OpenChangeDetail,
|
||||
RetryDetail,
|
||||
ThreadSelectedDetail,
|
||||
UnarchiveDetail,
|
||||
CopilotKitThreadsDrawer as CopilotKitThreadsDrawerElement,
|
||||
} from "@copilotkit/web-components/threads-drawer";
|
||||
// TODO(ENT-1051): import `CollapseChangeDetail` from
|
||||
// "@copilotkit/web-components/threads-drawer" once the parallel element PR that
|
||||
// adds the collapse feature (property `collapsible` + event `collapse-change`)
|
||||
// lands and is published; declared locally here because the built element types
|
||||
// in this worktree predate it.
|
||||
type CollapseChangeDetail = { collapsed: boolean };
|
||||
import { COPILOT_CHAT_CONFIGURATION } from "../../chat-configuration";
|
||||
import { CopilotKit } from "../../copilotkit";
|
||||
import { injectThreads } from "../../threads";
|
||||
import type { Thread } from "../../threads";
|
||||
|
||||
/**
|
||||
* Maps a {@link Thread} from the platform store onto the minimal
|
||||
* {@link DrawerThread} shape the `<copilotkit-threads-drawer>` element accepts.
|
||||
*
|
||||
* `lastRunAt` is spread conditionally so the key is absent (rather than
|
||||
* `undefined`) when the source thread has never been run — matching the
|
||||
* element's optional typing and avoiding spurious property pollution.
|
||||
*/
|
||||
function toDrawerThread(thread: Thread): DrawerThread {
|
||||
return {
|
||||
id: thread.id,
|
||||
name: thread.name,
|
||||
archived: thread.archived,
|
||||
createdAt: thread.createdAt,
|
||||
updatedAt: thread.updatedAt,
|
||||
...(thread.lastRunAt !== undefined ? { lastRunAt: thread.lastRunAt } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The Angular chat view container's element selector. Used to scope the
|
||||
* focus-return lookup so a multi-chat page focuses THIS drawer's composer.
|
||||
*/
|
||||
const CHAT_CONTAINER_SELECTOR = "copilot-chat-view";
|
||||
/**
|
||||
* The Angular chat input's element selector (`<textarea copilotChatTextarea>`).
|
||||
* Note: this is the Angular chat input, NOT React's `copilot-chat-textarea` or
|
||||
* Vue's `copilot-chat-input-textarea` `data-testid` — the Angular chat
|
||||
* components identify by element/attribute selector rather than `data-testid`.
|
||||
*/
|
||||
const CHAT_INPUT_SELECTOR = "textarea[copilotChatTextarea]";
|
||||
|
||||
/**
|
||||
* Returns the chat input element for focus-return after a thread is selected.
|
||||
*
|
||||
* Best-effort and SCOPED: walks up from the drawer element looking for an
|
||||
* ancestor chat-view container ({@link CHAT_CONTAINER_SELECTOR}), then returns
|
||||
* the chat input ({@link CHAT_INPUT_SELECTOR}) within that subtree. This avoids
|
||||
* focusing the wrong composer on a page hosting more than one chat, where a
|
||||
* document-global lookup would grab whichever input appears first in DOM order.
|
||||
*
|
||||
* Falls back to a document-global lookup when no scoping ancestor is found
|
||||
* (e.g. the drawer and chat share no common container, or headless usage), and
|
||||
* returns `null` when there is no chat input at all. Mirrors the React and Vue
|
||||
* wrappers' `findChatInput`, scoped to the Angular chat selectors.
|
||||
*
|
||||
* @param origin - The drawer element to scope the search from.
|
||||
*/
|
||||
function findChatInput(origin: Element | null): HTMLElement | null {
|
||||
if (typeof document === "undefined") return null;
|
||||
const container = origin?.closest?.(CHAT_CONTAINER_SELECTOR);
|
||||
if (container) {
|
||||
const scoped = container.querySelector<HTMLElement>(CHAT_INPUT_SELECTOR);
|
||||
if (scoped) return scoped;
|
||||
}
|
||||
return document.querySelector<HTMLElement>(CHAT_INPUT_SELECTOR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural directive that captures a per-row template for projection into
|
||||
* the `<copilotkit-threads-drawer>` element as light-DOM children with
|
||||
* `slot="row:{id}"`.
|
||||
*
|
||||
* Apply it to an `<ng-template>` that is a direct child of `<copilot-threads-drawer>`.
|
||||
* The implicit context variable (`let-t`) receives the full {@link Thread}
|
||||
* record for that row.
|
||||
*
|
||||
* @example
|
||||
* ```html
|
||||
* <copilot-threads-drawer>
|
||||
* <ng-template copilotThreadsDrawerRow let-t>
|
||||
* <span>{{ t.name }}</span>
|
||||
* </ng-template>
|
||||
* </copilot-threads-drawer>
|
||||
* ```
|
||||
*/
|
||||
@Directive({ selector: "[copilotThreadsDrawerRow]", standalone: true })
|
||||
export class CopilotThreadsDrawerRow {
|
||||
/** The captured template reference for the per-row content. */
|
||||
readonly template = inject(TemplateRef);
|
||||
}
|
||||
|
||||
/**
|
||||
* Angular wrapper around the `<copilotkit-threads-drawer>` Lit web component.
|
||||
*
|
||||
* Registers the custom element on construction (idempotent; SSR-guarded
|
||||
* internally by {@link defineCopilotKitThreadsDrawer}) and projects it into the DOM
|
||||
* via the `CUSTOM_ELEMENTS_SCHEMA`-enabled template.
|
||||
*
|
||||
* Thread list state is fetched from the Intelligence platform via
|
||||
* {@link injectThreads} and pushed onto the element's JS properties via an
|
||||
* `effect`, following the imperative property-assignment pattern used elsewhere
|
||||
* in this package (see `CopilotA2UIActivityRenderer`).
|
||||
*
|
||||
* DOM events emitted by the element are routed back to the chat configuration
|
||||
* and thread mutation methods via declarative Angular event bindings.
|
||||
*
|
||||
* @example
|
||||
* ```html
|
||||
* <copilot-threads-drawer data-testid="my-drawer" />
|
||||
* ```
|
||||
*/
|
||||
@Component({
|
||||
selector: "copilot-threads-drawer",
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
||||
imports: [NgTemplateOutlet],
|
||||
template: `
|
||||
<copilotkit-threads-drawer
|
||||
#drawer
|
||||
[attr.data-testid]="dataTestId()"
|
||||
[attr.recent-label]="recentLabel() ?? null"
|
||||
(thread-selected)="onThreadSelected($event)"
|
||||
(new-thread)="onNewThread()"
|
||||
(archive)="onArchive($event)"
|
||||
(unarchive)="onUnarchive($event)"
|
||||
(delete)="onDelete($event)"
|
||||
(filter-change)="onFilterChange()"
|
||||
(collapse-change)="onCollapseChange($event)"
|
||||
(retry)="onRetry($event)"
|
||||
(load-more)="onLoadMore()"
|
||||
(open-change)="onOpenChange($event)"
|
||||
(licensed)="onLicensed()"
|
||||
><ng-content></ng-content>
|
||||
@if (rowDirective(); as row) {
|
||||
@for (t of threads.threads(); track t.id) {
|
||||
<div [attr.slot]="'row:' + t.id">
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="row.template"
|
||||
[ngTemplateOutletContext]="{ $implicit: t }"
|
||||
></ng-container>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</copilotkit-threads-drawer>
|
||||
`,
|
||||
})
|
||||
export class CopilotThreadsDrawer {
|
||||
/**
|
||||
* Optional agent id whose threads this drawer lists/manages. Scopes the
|
||||
* {@link injectThreads} store (via the resolved-agent precedence: this input
|
||||
* → ambient config's `agentId` → {@link DEFAULT_AGENT_ID}). NOT forwarded to
|
||||
* the element (the element has no `agentId` property).
|
||||
*/
|
||||
readonly agentId = input<string | undefined>();
|
||||
|
||||
/**
|
||||
* Value applied as the `data-testid` attribute on the inner
|
||||
* `<copilotkit-threads-drawer>` element. Defaults to `"copilot-threads-drawer"`.
|
||||
*/
|
||||
readonly dataTestId = input<string>("copilot-threads-drawer", {
|
||||
alias: "data-testid",
|
||||
});
|
||||
|
||||
/**
|
||||
* Optional accessible/region + default-header label forwarded to the element's
|
||||
* `label` property; defaults to the element's own `"Threads"` when unset.
|
||||
*/
|
||||
readonly label = input<string | undefined>();
|
||||
|
||||
/**
|
||||
* Optional heading for the "Recent Conversations" section, forwarded to the
|
||||
* element's `recent-label` attribute; defaults to the element's own
|
||||
* `"Recent Conversations"` when unset.
|
||||
*/
|
||||
readonly recentLabel = input<string | undefined>();
|
||||
|
||||
/**
|
||||
* Whether the drawer offers a collapse toggle. Pushed onto the element's
|
||||
* `collapsible` PROPERTY (a default-true boolean, exactly like `licensed` — a
|
||||
* string attribute cannot represent it since any non-empty value is truthy).
|
||||
* When `false`, the drawer has no collapse toggle and is always expanded.
|
||||
* Defaults to the element's own `true` when unset.
|
||||
*/
|
||||
readonly collapsible = input<boolean | undefined>();
|
||||
|
||||
/**
|
||||
* Emits the new collapsed state whenever the drawer's collapsed state changes
|
||||
* (mirrors the element's `collapse-change` event).
|
||||
*/
|
||||
@Output() readonly collapseChange = new EventEmitter<boolean>();
|
||||
|
||||
/**
|
||||
* Optional host override for the thread-select action.
|
||||
*
|
||||
* When provided, this callback is invoked instead of driving
|
||||
* `config.setActiveThreadId`. Bound via `[onThreadSelect]="fn"`.
|
||||
*/
|
||||
readonly threadSelectHandler = input<
|
||||
((threadId: string) => void) | undefined
|
||||
>(undefined, {
|
||||
alias: "onThreadSelect",
|
||||
});
|
||||
|
||||
/**
|
||||
* Optional host override for the new-thread action.
|
||||
*
|
||||
* When provided, this callback is invoked instead of driving
|
||||
* `config.startNewThread`. The core `threads.startNewThread()` reset is
|
||||
* always called regardless. Bound via `[onNewThread]="fn"`.
|
||||
*/
|
||||
readonly newThreadHandler = input<(() => void) | undefined>(undefined, {
|
||||
alias: "onNewThread",
|
||||
});
|
||||
|
||||
/**
|
||||
* Page size for thread pagination. When set, threads load in pages of this
|
||||
* size and the element shows a "Load more" control while more remain. When
|
||||
* unset, the full list loads at once and no pagination control shows.
|
||||
*/
|
||||
readonly limit = input<number | undefined>();
|
||||
|
||||
/**
|
||||
* Destination the locked view's Upgrade CTA opens in a new tab. Forwarded to
|
||||
* the element's `licenseUrl` property; defaults to the element's built-in
|
||||
* CopilotKit Intelligence docs URL when unset. Set to an empty string to
|
||||
* suppress the default navigation and handle the click via `onLicensed`.
|
||||
*/
|
||||
readonly licenseUrl = input<string | undefined>();
|
||||
|
||||
/**
|
||||
* Optional host hook fired when the locked view's Upgrade CTA is clicked. The
|
||||
* element still performs its default navigation unless `licenseUrl` is blank;
|
||||
* use this for telemetry or to drive your own upgrade flow. Bound via
|
||||
* `[onLicensed]="fn"`.
|
||||
*/
|
||||
readonly licensedHandler = input<(() => void) | undefined>(undefined, {
|
||||
alias: "onLicensed",
|
||||
});
|
||||
|
||||
private readonly config = inject(COPILOT_CHAT_CONFIGURATION, {
|
||||
optional: true,
|
||||
});
|
||||
|
||||
private readonly copilotkit = inject(CopilotKit);
|
||||
|
||||
private readonly drawerRef = viewChild<
|
||||
unknown,
|
||||
ElementRef<CopilotKitThreadsDrawerElement>
|
||||
>("drawer", {
|
||||
read: ElementRef,
|
||||
});
|
||||
|
||||
/**
|
||||
* Optional per-row template directive projected as a direct child of this
|
||||
* component. When present, a `<div slot="row:{id}">` is rendered inside the
|
||||
* `<copilotkit-threads-drawer>` element for each thread, allowing the host to inject
|
||||
* custom light-DOM content into each row slot.
|
||||
*/
|
||||
protected readonly rowDirective = contentChild(CopilotThreadsDrawerRow);
|
||||
|
||||
/**
|
||||
* The resolved agent id for the threads store. Precedence:
|
||||
* 1. `agentId` input prop.
|
||||
* 2. Ambient `CopilotChatConfiguration.agentId`.
|
||||
* 3. {@link DEFAULT_AGENT_ID}.
|
||||
*/
|
||||
protected readonly resolvedAgentId = computed(
|
||||
() => this.agentId() ?? this.config?.agentId() ?? DEFAULT_AGENT_ID,
|
||||
);
|
||||
|
||||
/**
|
||||
* Normalized license context derived from the core's runtime-reported status,
|
||||
* via the same `@copilotkit/shared` helper the React provider uses.
|
||||
*/
|
||||
private readonly licenseContext = computed(() =>
|
||||
createLicenseContextValue(this.copilotkit.licenseStatus()),
|
||||
);
|
||||
|
||||
/**
|
||||
* Two-pronged license gate, mirroring the React wrapper. `checkFeature` fails
|
||||
* OPEN (returns true) when no license is configured, so it cannot by itself
|
||||
* detect the no-license case; we therefore also require a positive
|
||||
* license-present signal. Only a resolved `valid`/`expiring` status counts as
|
||||
* present — a resolved `none`/`expired`/`invalid` gates the drawer to the
|
||||
* locked view.
|
||||
*/
|
||||
protected readonly licensed = computed(() => {
|
||||
const ctx = this.licenseContext();
|
||||
const licensePresent = ctx.status === "valid" || ctx.status === "expiring";
|
||||
return licensePresent && ctx.checkFeature("threads");
|
||||
});
|
||||
|
||||
/**
|
||||
* Whether the runtime has not yet reported a license status. The status is
|
||||
* `null` until the first `/info` response lands; treat that window as
|
||||
* "not yet unlicensed" — show the loading state, never the locked view — so a
|
||||
* licensed drawer never flashes (or strands) the Upgrade CTA mid-resolution.
|
||||
*/
|
||||
protected readonly licensePending = computed(
|
||||
() => this.licenseContext().status === null,
|
||||
);
|
||||
|
||||
/** Live thread list from the Intelligence platform for the resolved agent. */
|
||||
protected readonly threads = injectThreads({
|
||||
agentId: this.resolvedAgentId,
|
||||
includeArchived: true,
|
||||
limit: this.limit,
|
||||
// While unlicensed, skip the thread fetch entirely: the element shows only
|
||||
// its locked view and no `/threads` request is issued.
|
||||
enabled: this.licensed,
|
||||
});
|
||||
|
||||
/** Thread records mapped to the element's {@link DrawerThread} shape. */
|
||||
protected readonly drawerThreads = computed(() =>
|
||||
this.threads.threads().map(toDrawerThread),
|
||||
);
|
||||
|
||||
/**
|
||||
* User-facing error message from the filtered list error signal, or `null`
|
||||
* when there is no error. Uses `listError` (not `error`) so developer/config
|
||||
* errors (missing runtime URL, runtime without thread endpoints) are excluded
|
||||
* and never displayed to end users.
|
||||
*/
|
||||
protected readonly errorMessage = computed(
|
||||
() => this.threads.listError()?.message ?? null,
|
||||
);
|
||||
|
||||
/**
|
||||
* The currently-active thread id, sourced from the ambient chat
|
||||
* configuration, or `null` when no configuration is present.
|
||||
*/
|
||||
protected readonly activeThreadId = computed(
|
||||
() => this.config?.threadId() ?? null,
|
||||
);
|
||||
|
||||
/**
|
||||
* User-facing fetch-more error message from the dedicated fetch-more error
|
||||
* signal, or `null`. Drives the element's inline "couldn't load more — retry"
|
||||
* panel without disturbing the loaded list or the initial-list `error`.
|
||||
*/
|
||||
protected readonly fetchMoreErrorMessage = computed(
|
||||
() => this.threads.fetchMoreError()?.message ?? null,
|
||||
);
|
||||
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
/**
|
||||
* Provider-less fallback open-state. Without a surrounding chat configuration
|
||||
* there is no shared open-state to bind to, so the wrapper keeps its own
|
||||
* local state. Starts CLOSED — matching the configuration's own default — so
|
||||
* the element does not spring open (and scroll-lock the page on mobile) on
|
||||
* load, and the element's `open-change` events still toggle it.
|
||||
*/
|
||||
private readonly localDrawerOpen = signal(false);
|
||||
|
||||
/**
|
||||
* The effective drawer open-state: the ambient chat configuration's
|
||||
* `drawerOpen` when present, else the provider-less {@link localDrawerOpen}.
|
||||
* Pushed onto the element's controlled `open` property in the effect below.
|
||||
*/
|
||||
protected readonly drawerOpen = computed(() =>
|
||||
this.config ? this.config.drawerOpen() : this.localDrawerOpen(),
|
||||
);
|
||||
|
||||
constructor() {
|
||||
defineCopilotKitThreadsDrawer();
|
||||
|
||||
// Announce drawer presence to the surrounding chat configuration so a
|
||||
// future header launcher can render, and de-register on destroy. Mirrors
|
||||
// the React (`registerDrawer()` effect) and Vue (`onScopeDispose`) wrappers.
|
||||
const unregisterDrawer = this.config?.registerDrawer();
|
||||
if (unregisterDrawer) {
|
||||
this.destroyRef.onDestroy(unregisterDrawer);
|
||||
}
|
||||
|
||||
// Push signal-derived values onto the element's JS properties every time
|
||||
// any reactive dependency changes. Using an effect (rather than template
|
||||
// bindings) is required because Lit elements accept object/boolean domains
|
||||
// only as JS properties, not as HTML attributes.
|
||||
effect(() => {
|
||||
const el = this.drawerRef()?.nativeElement;
|
||||
if (!el) return;
|
||||
el.threads = this.drawerThreads();
|
||||
// While the license is still resolving, force the loading state so the
|
||||
// element shows its spinner instead of an empty/locked body.
|
||||
el.loading = this.threads.isLoading() || this.licensePending();
|
||||
el.error = this.errorMessage();
|
||||
el.fetchMoreError = this.fetchMoreErrorMessage();
|
||||
el.activeThreadId = this.activeThreadId();
|
||||
el.hasMore = this.threads.hasMoreThreads();
|
||||
el.fetchingMore = this.threads.isFetchingMoreThreads();
|
||||
// Drive the element's controlled `open` from the (config-backed or local)
|
||||
// open-state so the element does not default open=true on load.
|
||||
el.open = this.drawerOpen();
|
||||
// Pending counts as licensed for rendering: the element shows the locked
|
||||
// view only when `licensed` is false, so keeping it true until the status
|
||||
// resolves prevents the locked view from flashing mid-resolution.
|
||||
el.licensed = this.licensed() || this.licensePending();
|
||||
if (this.label() !== undefined) el.label = this.label() as string;
|
||||
const licenseUrl = this.licenseUrl();
|
||||
if (licenseUrl !== undefined) el.licenseUrl = licenseUrl;
|
||||
// `collapsible` is a default-true boolean PROPERTY (like `licensed`);
|
||||
// leave the element's own default in place when the input is unset.
|
||||
const collapsible = this.collapsible();
|
||||
if (collapsible !== undefined) {
|
||||
// TODO(ENT-1051): drop the intersection cast once the published element
|
||||
// type declares `collapsible` (see the local CollapseChangeDetail note).
|
||||
(
|
||||
el as CopilotKitThreadsDrawerElement & { collapsible: boolean }
|
||||
).collapsible = collapsible;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the `thread-selected` event from the drawer element.
|
||||
*
|
||||
* When a `threadSelectHandler` override is provided by the host, it is called
|
||||
* exclusively. Otherwise, the ambient chat configuration is driven directly so
|
||||
* a bare `<copilot-threads-drawer>` works without any host wiring.
|
||||
*
|
||||
* @param event - The raw DOM event; cast to `CustomEvent<ThreadSelectedDetail>` to extract `threadId`.
|
||||
*/
|
||||
protected onThreadSelected(event: Event): void {
|
||||
const { threadId } = (event as CustomEvent<ThreadSelectedDetail>).detail;
|
||||
const handler = this.threadSelectHandler();
|
||||
if (handler) {
|
||||
handler(threadId);
|
||||
} else {
|
||||
this.config?.setActiveThreadId(threadId, { explicit: true });
|
||||
}
|
||||
// Return focus to the chat input so keyboard users land in the composer.
|
||||
// Scope the lookup to this drawer's own chat (not document-global). Mirrors
|
||||
// the React (`findChatInput(...).focus()`) and Vue (`focusChatInput()`)
|
||||
// wrappers.
|
||||
findChatInput(this.drawerRef()?.nativeElement ?? null)?.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the `open-change` event from the drawer element.
|
||||
*
|
||||
* Drives the ambient chat configuration's `setDrawerOpen` when present, else
|
||||
* the provider-less {@link localDrawerOpen} fallback — mirroring the React
|
||||
* and Vue wrappers so the element's open-state stays coordinated.
|
||||
*
|
||||
* @param event - The raw DOM event; cast to `CustomEvent<OpenChangeDetail>` to extract `open`.
|
||||
*/
|
||||
protected onOpenChange(event: Event): void {
|
||||
const { open } = (event as CustomEvent<OpenChangeDetail>).detail;
|
||||
if (this.config) {
|
||||
this.config.setDrawerOpen(open);
|
||||
} else {
|
||||
this.localDrawerOpen.set(open);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the `new-thread` event from the drawer element.
|
||||
*
|
||||
* Always resets the core thread store to a fresh, non-explicit client-side
|
||||
* thread first. When a `newThreadHandler` override is provided by the host,
|
||||
* it is called exclusively. Otherwise, the ambient chat configuration is
|
||||
* driven directly so a bare `<copilot-threads-drawer>` works without any host wiring.
|
||||
*/
|
||||
protected onNewThread(): void {
|
||||
this.threads.startNewThread();
|
||||
const handler = this.newThreadHandler();
|
||||
if (handler) {
|
||||
handler();
|
||||
} else {
|
||||
this.config?.startNewThread();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the `archive` event from the drawer element.
|
||||
*
|
||||
* Delegates to the threads mutation and logs any failure to the console.
|
||||
*
|
||||
* @param event - The raw DOM event; cast to `CustomEvent<ArchiveDetail>` to extract `threadId`.
|
||||
*/
|
||||
protected onArchive(event: Event): void {
|
||||
const { threadId } = (event as CustomEvent<ArchiveDetail>).detail;
|
||||
this.threads.archiveThread(threadId).catch((err) => {
|
||||
console.error("CopilotThreadsDrawer: archiveThread failed", err);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the `unarchive` event from the drawer element.
|
||||
*
|
||||
* Delegates to the threads mutation and logs any failure to the console.
|
||||
*
|
||||
* @param event - The raw DOM event; cast to `CustomEvent<UnarchiveDetail>` to extract `threadId`.
|
||||
*/
|
||||
protected onUnarchive(event: Event): void {
|
||||
const { threadId } = (event as CustomEvent<UnarchiveDetail>).detail;
|
||||
this.threads.unarchiveThread(threadId).catch((err) => {
|
||||
console.error("CopilotThreadsDrawer: unarchiveThread failed", err);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the `delete` event from the drawer element.
|
||||
*
|
||||
* Deleting the active thread resets to a fresh, non-explicit thread so the
|
||||
* user is not stranded on a now-gone conversation. Archiving the active
|
||||
* thread keeps the user viewing it.
|
||||
*
|
||||
* The core `threads.startNewThread()` reset is always called when the deleted
|
||||
* thread was active. When a `newThreadHandler` override is provided by the
|
||||
* host, it is called exclusively; otherwise the ambient chat configuration is
|
||||
* driven directly.
|
||||
*
|
||||
* @param event - The raw DOM event; cast to `CustomEvent<DeleteDetail>` to extract `threadId`.
|
||||
*/
|
||||
protected onDelete(event: Event): void {
|
||||
const { threadId } = (event as CustomEvent<DeleteDetail>).detail;
|
||||
const wasActive = threadId === this.activeThreadId();
|
||||
this.threads
|
||||
.deleteThread(threadId)
|
||||
.then(() => {
|
||||
if (wasActive) {
|
||||
this.threads.startNewThread();
|
||||
const handler = this.newThreadHandler();
|
||||
if (handler) {
|
||||
handler();
|
||||
} else {
|
||||
this.config?.startNewThread();
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("CopilotThreadsDrawer: deleteThread failed", err);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the `filter-change` event from the drawer element.
|
||||
*
|
||||
* The element owns the Active/All filter; on change we refetch so the list
|
||||
* reflects the server.
|
||||
*/
|
||||
protected onFilterChange(): void {
|
||||
this.threads.refetchThreads();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the `collapse-change` event from the drawer element — re-emits the
|
||||
* new collapsed state through the component's `collapseChange` output so hosts
|
||||
* can observe (or persist) the drawer's collapsed state.
|
||||
*
|
||||
* @param event - The raw DOM event; cast to `CustomEvent<CollapseChangeDetail>` to extract `collapsed`.
|
||||
*/
|
||||
protected onCollapseChange(event: Event): void {
|
||||
const { collapsed } = (event as CustomEvent<CollapseChangeDetail>).detail;
|
||||
this.collapseChange.emit(collapsed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the `retry` event from the drawer element.
|
||||
*
|
||||
* `scope` distinguishes an initial fetch retry from a fetch-more retry so
|
||||
* the correct pagination operation is invoked.
|
||||
*
|
||||
* @param event - The raw DOM event; cast to `CustomEvent<RetryDetail>` to extract `scope`.
|
||||
*/
|
||||
protected onRetry(event: Event): void {
|
||||
const { scope } = (event as CustomEvent<RetryDetail>).detail;
|
||||
if (scope === "fetch-more") {
|
||||
this.threads.fetchMoreThreads();
|
||||
} else {
|
||||
this.threads.refetchThreads();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the `load-more` event from the drawer element — advances pagination
|
||||
* by fetching the next page. No-op when there is no next page (the element
|
||||
* only surfaces the "Load more" control while `hasMoreThreads` is true).
|
||||
*/
|
||||
protected onLoadMore(): void {
|
||||
this.threads.fetchMoreThreads();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the `licensed` event from the drawer element (Upgrade CTA click).
|
||||
*
|
||||
* The element performs its own default navigation to `licenseUrl`; this hook
|
||||
* lets the host observe the click (e.g. for telemetry) or drive a custom
|
||||
* upgrade flow when provided via `[onLicensed]`.
|
||||
*/
|
||||
protected onLicensed(): void {
|
||||
this.licensedHandler()?.();
|
||||
}
|
||||
}
|
||||
+374
@@ -0,0 +1,374 @@
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
CopilotOpenGenerativeUIActivityRenderer,
|
||||
CopilotOpenGenerativeUIRenderer,
|
||||
OPEN_GENERATIVE_UI_WEBSANDBOX_LOADER,
|
||||
} from "../open-generative-ui-activity-renderer";
|
||||
import { COPILOT_KIT_CONFIG } from "../../../config";
|
||||
import type { OpenGenerativeUIContent } from "../../../open-generative-ui";
|
||||
|
||||
const mockRun = vi.fn().mockResolvedValue(undefined);
|
||||
const mockDestroy = vi.fn();
|
||||
let mockCreate: ReturnType<typeof vi.fn>;
|
||||
let mockLoadWebsandbox: ReturnType<typeof vi.fn>;
|
||||
|
||||
function createSandbox(frameContainer?: HTMLElement) {
|
||||
const iframe = document.createElement("iframe");
|
||||
frameContainer?.appendChild(iframe);
|
||||
return {
|
||||
iframe,
|
||||
promise: Promise.resolve(),
|
||||
run: mockRun,
|
||||
destroy: () => {
|
||||
mockDestroy();
|
||||
iframe.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function flushSandboxImport(fixture: ComponentFixture<unknown>) {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
function setContent(
|
||||
fixture: ComponentFixture<CopilotOpenGenerativeUIRenderer>,
|
||||
content: OpenGenerativeUIContent,
|
||||
) {
|
||||
fixture.componentRef.setInput("content", content);
|
||||
fixture.detectChanges();
|
||||
}
|
||||
|
||||
describe("CopilotOpenGenerativeUIRenderer", () => {
|
||||
let fixture: ComponentFixture<CopilotOpenGenerativeUIRenderer>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCreate = vi.fn(
|
||||
(_localApi: unknown, options: { frameContainer: HTMLElement }) =>
|
||||
createSandbox(options.frameContainer),
|
||||
);
|
||||
mockLoadWebsandbox = vi.fn().mockResolvedValue({ create: mockCreate });
|
||||
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
imports: [CopilotOpenGenerativeUIRenderer],
|
||||
providers: [
|
||||
{
|
||||
provide: COPILOT_KIT_CONFIG,
|
||||
useValue: {
|
||||
openGenerativeUI: {
|
||||
sandboxFunctions: [
|
||||
{
|
||||
name: "setTheme",
|
||||
description: "Set the host theme",
|
||||
parameters: z.object({ theme: z.string() }),
|
||||
handler: vi.fn(),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: OPEN_GENERATIVE_UI_WEBSANDBOX_LOADER,
|
||||
useValue: mockLoadWebsandbox,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
fixture = TestBed.createComponent(CopilotOpenGenerativeUIRenderer);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture.destroy();
|
||||
});
|
||||
|
||||
it("renders the same placeholder shell while no sandbox is visible", async () => {
|
||||
setContent(fixture, { initialHeight: 300 });
|
||||
await flushSandboxImport(fixture);
|
||||
|
||||
const container = fixture.nativeElement.querySelector(
|
||||
'[data-testid="open-generative-ui-renderer"]',
|
||||
) as HTMLElement;
|
||||
|
||||
expect(container.style.height).toBe("300px");
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-testid="open-generative-ui-placeholder"]',
|
||||
),
|
||||
).not.toBeNull();
|
||||
expect(mockCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates a preview sandbox when styled HTML is streaming", async () => {
|
||||
setContent(fixture, {
|
||||
css: ".metric { color: red; }",
|
||||
cssComplete: true,
|
||||
html: ['<body><div class="metric">Revenue'],
|
||||
htmlComplete: false,
|
||||
});
|
||||
await flushSandboxImport(fixture);
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledTimes(1);
|
||||
const [, options] = mockCreate.mock.calls[0];
|
||||
expect(options.frameContent).toBe("<head></head><body></body>");
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-testid="open-generative-ui-preview-sandbox"]',
|
||||
),
|
||||
).not.toBeNull();
|
||||
expect(mockRun).toHaveBeenCalledWith(
|
||||
expect.stringContaining("document.body.innerHTML"),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the placeholder until CSS is complete", async () => {
|
||||
setContent(fixture, {
|
||||
cssComplete: false,
|
||||
html: ['<body><div class="metric">Revenue'],
|
||||
htmlComplete: false,
|
||||
});
|
||||
await flushSandboxImport(fixture);
|
||||
|
||||
expect(mockCreate).not.toHaveBeenCalled();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-testid="open-generative-ui-placeholder"]',
|
||||
),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("creates a final sandbox when HTML is complete", async () => {
|
||||
setContent(fixture, {
|
||||
css: ".metric { color: blue; }",
|
||||
cssComplete: true,
|
||||
html: ["<body><p>Hello</p></body>"],
|
||||
htmlComplete: true,
|
||||
});
|
||||
await flushSandboxImport(fixture);
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledTimes(1);
|
||||
const [localApi, options] = mockCreate.mock.calls[0];
|
||||
expect(Object.keys(localApi)).toEqual(["setTheme"]);
|
||||
expect(options.frameContent).toContain("<head>");
|
||||
expect(options.frameContent).toContain(
|
||||
"<style>.metric { color: blue; }</style>",
|
||||
);
|
||||
expect(options.frameContent).toContain("<body><p>Hello</p></body>");
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-testid="open-generative-ui-final-sandbox"]',
|
||||
),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("destroys the preview sandbox when final HTML arrives", async () => {
|
||||
setContent(fixture, {
|
||||
css: ".metric { color: red; }",
|
||||
cssComplete: true,
|
||||
html: ['<body><div class="metric">Revenue'],
|
||||
htmlComplete: false,
|
||||
});
|
||||
await flushSandboxImport(fixture);
|
||||
|
||||
setContent(fixture, {
|
||||
css: ".metric { color: red; }",
|
||||
cssComplete: true,
|
||||
html: ['<body><div class="metric">Revenue</div></body>'],
|
||||
htmlComplete: true,
|
||||
});
|
||||
await flushSandboxImport(fixture);
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledTimes(2);
|
||||
expect(mockDestroy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("queues JS functions and expressions into the final sandbox", async () => {
|
||||
setContent(fixture, {
|
||||
html: ["<body><div id='app'></div></body>"],
|
||||
htmlComplete: true,
|
||||
jsFunctions: "function paint() { return true; }",
|
||||
jsExpressions: ["paint()", "document.body.dataset.ready = 'true'"],
|
||||
});
|
||||
await flushSandboxImport(fixture);
|
||||
|
||||
expect(mockRun).toHaveBeenCalledWith("function paint() { return true; }");
|
||||
expect(mockRun).toHaveBeenCalledWith("paint()");
|
||||
expect(mockRun).toHaveBeenCalledWith(
|
||||
"document.body.dataset.ready = 'true'",
|
||||
);
|
||||
});
|
||||
|
||||
it("injects JS that arrived before final HTML into the final sandbox", async () => {
|
||||
setContent(fixture, {
|
||||
jsFunctions: "function paintEarly() { return true; }",
|
||||
jsExpressions: ["paintEarly()"],
|
||||
});
|
||||
await flushSandboxImport(fixture);
|
||||
|
||||
expect(mockCreate).not.toHaveBeenCalled();
|
||||
expect(mockRun).not.toHaveBeenCalledWith(
|
||||
"function paintEarly() { return true; }",
|
||||
);
|
||||
|
||||
setContent(fixture, {
|
||||
html: ["<body><div id='app'></div></body>"],
|
||||
htmlComplete: true,
|
||||
jsFunctions: "function paintEarly() { return true; }",
|
||||
jsExpressions: ["paintEarly()"],
|
||||
});
|
||||
await flushSandboxImport(fixture);
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledTimes(1);
|
||||
expect(mockRun).toHaveBeenCalledWith(
|
||||
"function paintEarly() { return true; }",
|
||||
);
|
||||
expect(mockRun).toHaveBeenCalledWith("paintEarly()");
|
||||
});
|
||||
|
||||
it("does not recreate the final sandbox when only expressions grow", async () => {
|
||||
const html = ["<body><div id='app'></div></body>"];
|
||||
setContent(fixture, {
|
||||
html,
|
||||
htmlComplete: true,
|
||||
jsExpressions: ["document.body.dataset.step = 'one'"],
|
||||
});
|
||||
await flushSandboxImport(fixture);
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledTimes(1);
|
||||
|
||||
setContent(fixture, {
|
||||
html,
|
||||
htmlComplete: true,
|
||||
jsExpressions: [
|
||||
"document.body.dataset.step = 'one'",
|
||||
"document.body.dataset.step = 'two'",
|
||||
],
|
||||
});
|
||||
await flushSandboxImport(fixture);
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledTimes(1);
|
||||
expect(mockRun).toHaveBeenCalledWith("document.body.dataset.step = 'two'");
|
||||
});
|
||||
|
||||
it("measures the final sandbox height once, even after later content updates", async () => {
|
||||
const measureCallCount = () =>
|
||||
mockRun.mock.calls.filter(
|
||||
([code]) => typeof code === "string" && code.includes("__ck_resize"),
|
||||
).length;
|
||||
|
||||
setContent(fixture, {
|
||||
html: ["<body><p>Chart</p></body>"],
|
||||
htmlComplete: true,
|
||||
generating: true,
|
||||
});
|
||||
await flushSandboxImport(fixture);
|
||||
expect(measureCallCount()).toBe(0);
|
||||
|
||||
setContent(fixture, {
|
||||
html: ["<body><p>Chart</p></body>"],
|
||||
htmlComplete: true,
|
||||
generating: false,
|
||||
});
|
||||
await flushSandboxImport(fixture);
|
||||
expect(measureCallCount()).toBe(1);
|
||||
|
||||
setContent(fixture, {
|
||||
html: ["<body><p>Chart</p></body>"],
|
||||
htmlComplete: true,
|
||||
generating: false,
|
||||
initialHeight: 321,
|
||||
});
|
||||
await flushSandboxImport(fixture);
|
||||
expect(measureCallCount()).toBe(1);
|
||||
});
|
||||
|
||||
it("tears down a completed sandbox immediately when a fresh generation starts", async () => {
|
||||
setContent(fixture, {
|
||||
css: ".dashboard { color: blue; }",
|
||||
cssComplete: true,
|
||||
html: ['<body><main class="dashboard">Old dashboard</main></body>'],
|
||||
htmlComplete: true,
|
||||
generating: false,
|
||||
});
|
||||
await flushSandboxImport(fixture);
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-testid="open-generative-ui-final-sandbox"]',
|
||||
),
|
||||
).not.toBeNull();
|
||||
|
||||
setContent(fixture, {
|
||||
initialHeight: 360,
|
||||
generating: true,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(mockDestroy).toHaveBeenCalled();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-testid="open-generative-ui-final-sandbox"]',
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
'[data-testid="open-generative-ui-placeholder"]',
|
||||
),
|
||||
).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CopilotOpenGenerativeUIActivityRenderer", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCreate = vi.fn(
|
||||
(_localApi: unknown, options: { frameContainer: HTMLElement }) =>
|
||||
createSandbox(options.frameContainer),
|
||||
);
|
||||
mockLoadWebsandbox = vi.fn().mockResolvedValue({ create: mockCreate });
|
||||
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
imports: [CopilotOpenGenerativeUIActivityRenderer],
|
||||
providers: [
|
||||
{
|
||||
provide: COPILOT_KIT_CONFIG,
|
||||
useValue: { openGenerativeUI: {} },
|
||||
},
|
||||
{
|
||||
provide: OPEN_GENERATIVE_UI_WEBSANDBOX_LOADER,
|
||||
useValue: mockLoadWebsandbox,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("adapts activity messages to the renderer component", () => {
|
||||
const fixture = TestBed.createComponent(
|
||||
CopilotOpenGenerativeUIActivityRenderer,
|
||||
);
|
||||
fixture.componentRef.setInput("activityType", "open-generative-ui");
|
||||
fixture.componentRef.setInput("content", { initialHeight: 180 });
|
||||
fixture.componentRef.setInput("message", {
|
||||
id: "activity-1",
|
||||
role: "activity",
|
||||
activityType: "open-generative-ui",
|
||||
content: { initialHeight: 180 },
|
||||
});
|
||||
fixture.componentRef.setInput("agent", undefined);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector(
|
||||
"copilot-open-generative-ui-renderer",
|
||||
),
|
||||
).not.toBeNull();
|
||||
|
||||
fixture.destroy();
|
||||
});
|
||||
});
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CopilotOpenGenerativeUIToolRenderer } from "../open-generative-ui-tool-renderer";
|
||||
|
||||
describe("CopilotOpenGenerativeUIToolRenderer", () => {
|
||||
let fixture: ComponentFixture<CopilotOpenGenerativeUIToolRenderer>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
imports: [CopilotOpenGenerativeUIToolRenderer],
|
||||
});
|
||||
fixture = TestBed.createComponent(CopilotOpenGenerativeUIToolRenderer);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture.destroy();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("shows streamed placeholder messages while the sandbox tool is in progress", () => {
|
||||
fixture.componentRef.setInput("toolCall", {
|
||||
status: "in-progress",
|
||||
args: {
|
||||
placeholderMessages: ["Planning dashboard", "Choosing chart library"],
|
||||
},
|
||||
result: undefined,
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
"Choosing chart library",
|
||||
);
|
||||
|
||||
vi.advanceTimersByTime(5000);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain("Planning dashboard");
|
||||
});
|
||||
|
||||
it("hides once the tool call is complete", () => {
|
||||
fixture.componentRef.setInput("toolCall", {
|
||||
status: "complete",
|
||||
args: {
|
||||
initialHeight: 300,
|
||||
placeholderMessages: ["Generating UI"],
|
||||
},
|
||||
result: "UI generated",
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.textContent.trim()).toBe("");
|
||||
});
|
||||
});
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ensureHead,
|
||||
extractCompleteStyles,
|
||||
injectCssIntoHtml,
|
||||
processPartialHtml,
|
||||
} from "../process-partial-html";
|
||||
|
||||
describe("Open Generative UI partial HTML processing", () => {
|
||||
it("keeps complete style tags in the preview head and strips incomplete tags", () => {
|
||||
const partial =
|
||||
'<style>.card { color: red; }</style><div class="card">Revenue<style>.broken';
|
||||
|
||||
expect(extractCompleteStyles(partial)).toBe(
|
||||
"<style>.card { color: red; }</style>",
|
||||
);
|
||||
expect(processPartialHtml(partial)).toBe('<div class="card">Revenue');
|
||||
});
|
||||
|
||||
it("injects generated CSS into existing or synthesized head tags", () => {
|
||||
expect(
|
||||
injectCssIntoHtml(
|
||||
"<html><head><title>Demo</title></head><body>Body</body></html>",
|
||||
".card { color: red; }",
|
||||
),
|
||||
).toContain("<style>.card { color: red; }</style></head>");
|
||||
|
||||
expect(
|
||||
injectCssIntoHtml("<body>Body</body>", ".metric { color: blue; }"),
|
||||
).toContain("<head><style>.metric { color: blue; }</style></head>");
|
||||
});
|
||||
|
||||
it("ensures sandbox content has a head element", () => {
|
||||
expect(ensureHead("<body>Body</body>")).toBe(
|
||||
"<head></head><body>Body</body>",
|
||||
);
|
||||
expect(ensureHead("<html><head></head><body>Body</body></html>")).toBe(
|
||||
"<html><head></head><body>Body</body></html>",
|
||||
);
|
||||
});
|
||||
});
|
||||
+598
@@ -0,0 +1,598 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
DestroyRef,
|
||||
ElementRef,
|
||||
afterRenderEffect,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
untracked,
|
||||
viewChild,
|
||||
InjectionToken,
|
||||
} from "@angular/core";
|
||||
import type { AbstractAgent, ActivityMessage } from "@ag-ui/client";
|
||||
import type { API } from "@jetbrains/websandbox/dist/types";
|
||||
import type { ActivityRenderer } from "../../activity-renderer";
|
||||
import type { OpenGenerativeUIContent } from "../../open-generative-ui";
|
||||
import { injectCopilotKitConfig } from "../../config";
|
||||
import {
|
||||
ensureHead,
|
||||
extractCompleteStyles,
|
||||
injectCssIntoHtml,
|
||||
processPartialHtml,
|
||||
} from "./process-partial-html";
|
||||
import {
|
||||
parseOpenGenerativeUIContent,
|
||||
resolveWebsandboxConstructor,
|
||||
shouldFlushOpenGenerativeUIImmediately,
|
||||
type WebsandboxConstructor,
|
||||
type WebsandboxInstance,
|
||||
type WebsandboxModuleShape,
|
||||
} from "./open-generative-ui-renderer-utils";
|
||||
|
||||
export const OPEN_GENERATIVE_UI_WEBSANDBOX_LOADER = new InjectionToken<
|
||||
() => Promise<WebsandboxConstructor>
|
||||
>("OPEN_GENERATIVE_UI_WEBSANDBOX_LOADER", {
|
||||
providedIn: "root",
|
||||
factory: () => async (): Promise<WebsandboxConstructor> => {
|
||||
const module =
|
||||
(await import("@jetbrains/websandbox")) as unknown as WebsandboxModuleShape;
|
||||
return resolveWebsandboxConstructor(module);
|
||||
},
|
||||
});
|
||||
|
||||
const THROTTLE_MS = 1000;
|
||||
|
||||
type OpenGenerativeUIRenderState = {
|
||||
content: OpenGenerativeUIContent;
|
||||
fullHtml: string | undefined;
|
||||
css: string | undefined;
|
||||
hasPreview: boolean;
|
||||
previewBody: string | undefined;
|
||||
previewStyles: string;
|
||||
jsFunctions: string | undefined;
|
||||
jsExpressions: string[] | undefined;
|
||||
generatingDone: boolean;
|
||||
};
|
||||
|
||||
@Component({
|
||||
selector: "copilot-open-generative-ui-renderer",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div
|
||||
#container
|
||||
data-testid="open-generative-ui-renderer"
|
||||
class="copilot-open-generative-ui-container"
|
||||
[style.height.px]="height()"
|
||||
[class.has-visible-sandbox]="hasVisibleSandbox()"
|
||||
>
|
||||
@if (!hasVisibleSandbox() && isGenerating()) {
|
||||
<div
|
||||
data-testid="open-generative-ui-placeholder"
|
||||
class="copilot-open-generative-ui-placeholder"
|
||||
>
|
||||
<svg
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
class="copilot-open-generative-ui-spinner"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" stroke="#e0e0e0" stroke-width="3" />
|
||||
<path
|
||||
d="M12 2a10 10 0 0 1 10 10"
|
||||
stroke="#999"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
}
|
||||
@if (hasVisibleSandbox() && isGenerating()) {
|
||||
<div
|
||||
data-testid="open-generative-ui-progress-overlay"
|
||||
class="copilot-open-generative-ui-overlay"
|
||||
>
|
||||
<svg
|
||||
width="48"
|
||||
height="48"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
class="copilot-open-generative-ui-spinner"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" stroke="#e0e0e0" stroke-width="3" />
|
||||
<path
|
||||
d="M12 2a10 10 0 0 1 10 10"
|
||||
stroke="#999"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.copilot-open-generative-ui-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
background-color: #f5f5f5;
|
||||
border: 1px solid #e0e0e0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.copilot-open-generative-ui-container.has-visible-sandbox {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.copilot-open-generative-ui-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.copilot-open-generative-ui-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 10;
|
||||
pointer-events: all;
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.copilot-open-generative-ui-spinner {
|
||||
animation: copilot-open-generative-ui-spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes copilot-open-generative-ui-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class CopilotOpenGenerativeUIRenderer {
|
||||
readonly content = input.required<OpenGenerativeUIContent>();
|
||||
|
||||
private readonly containerRef = viewChild<
|
||||
unknown,
|
||||
ElementRef<HTMLDivElement>
|
||||
>("container", { read: ElementRef });
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly config = injectCopilotKitConfig();
|
||||
private readonly loadWebsandboxModule = inject(
|
||||
OPEN_GENERATIVE_UI_WEBSANDBOX_LOADER,
|
||||
);
|
||||
private readonly throttledContent = signal<OpenGenerativeUIContent>({});
|
||||
private throttleTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
private latestContent: OpenGenerativeUIContent = {};
|
||||
private sandbox: WebsandboxInstance | undefined;
|
||||
private previewSandbox: WebsandboxInstance | undefined;
|
||||
private sandboxReady = false;
|
||||
private previewReady = false;
|
||||
private finalSandboxKey: string | undefined;
|
||||
private executedExpressionIndex = 0;
|
||||
private pendingQueue: string[] = [];
|
||||
private jsFunctionsInjected = false;
|
||||
private heightMeasured = false;
|
||||
private autoHeight = signal<number | undefined>(undefined);
|
||||
private hasReceivedContent = false;
|
||||
|
||||
protected readonly fullHtml = computed(() => {
|
||||
const content = this.throttledContent();
|
||||
return content.htmlComplete && content.html?.length
|
||||
? content.html.join("")
|
||||
: undefined;
|
||||
});
|
||||
|
||||
protected readonly css = computed(() => {
|
||||
const content = this.throttledContent();
|
||||
return content.cssComplete ? content.css : undefined;
|
||||
});
|
||||
|
||||
protected readonly partialHtml = computed(() => {
|
||||
const content = this.throttledContent();
|
||||
return !content.htmlComplete && content.html?.length
|
||||
? content.html.join("")
|
||||
: undefined;
|
||||
});
|
||||
|
||||
protected readonly previewBody = computed(() => {
|
||||
const partialHtml = this.partialHtml();
|
||||
return partialHtml ? processPartialHtml(partialHtml) : undefined;
|
||||
});
|
||||
|
||||
protected readonly previewStyles = computed(() => {
|
||||
const partialHtml = this.partialHtml();
|
||||
return partialHtml ? extractCompleteStyles(partialHtml) : "";
|
||||
});
|
||||
|
||||
protected readonly hasPreview = computed(
|
||||
() => !!this.throttledContent().cssComplete && !!this.previewBody()?.trim(),
|
||||
);
|
||||
|
||||
protected readonly hasVisibleSandbox = computed(
|
||||
() => !!this.fullHtml() || this.hasPreview(),
|
||||
);
|
||||
|
||||
protected readonly height = computed(
|
||||
() => this.autoHeight() ?? this.throttledContent().initialHeight ?? 200,
|
||||
);
|
||||
|
||||
protected readonly isGenerating = computed(
|
||||
() => this.throttledContent().generating !== false,
|
||||
);
|
||||
|
||||
private readonly renderState = computed<OpenGenerativeUIRenderState>(() => {
|
||||
const content = this.throttledContent();
|
||||
return {
|
||||
content,
|
||||
fullHtml: this.fullHtml(),
|
||||
css: this.css(),
|
||||
hasPreview: this.hasPreview(),
|
||||
previewBody: this.previewBody(),
|
||||
previewStyles: this.previewStyles(),
|
||||
jsFunctions: content.jsFunctions,
|
||||
jsExpressions: content.jsExpressions,
|
||||
generatingDone: content.generating === false,
|
||||
};
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.destroyRef.onDestroy(() => {
|
||||
this.clearThrottle();
|
||||
this.destroyPreviewSandbox();
|
||||
this.destroyFinalSandbox();
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const next = parseOpenGenerativeUIContent(this.content());
|
||||
this.latestContent = next;
|
||||
const previous = untracked(() => this.throttledContent());
|
||||
|
||||
if (!this.hasReceivedContent) {
|
||||
this.hasReceivedContent = true;
|
||||
this.clearThrottle();
|
||||
this.throttledContent.set(next);
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldFlushOpenGenerativeUIImmediately(previous, next)) {
|
||||
this.clearThrottle();
|
||||
this.throttledContent.set(next);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.throttleTimer) {
|
||||
this.throttleTimer = setTimeout(() => {
|
||||
this.throttleTimer = undefined;
|
||||
this.throttledContent.set(this.latestContent);
|
||||
}, THROTTLE_MS);
|
||||
}
|
||||
});
|
||||
|
||||
afterRenderEffect({
|
||||
write: () => {
|
||||
const state = this.renderState();
|
||||
this.containerRef();
|
||||
this.reconcileSandboxState(state);
|
||||
|
||||
if (!state.generatingDone || this.heightMeasured || !this.sandbox) {
|
||||
return;
|
||||
}
|
||||
this.heightMeasured = true;
|
||||
const cleanup = this.measureFinalHeight();
|
||||
if (cleanup) this.destroyRef.onDestroy(cleanup);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private clearThrottle(): void {
|
||||
if (!this.throttleTimer) return;
|
||||
clearTimeout(this.throttleTimer);
|
||||
this.throttleTimer = undefined;
|
||||
}
|
||||
|
||||
private async loadWebsandbox(): Promise<WebsandboxConstructor> {
|
||||
return this.loadWebsandboxModule();
|
||||
}
|
||||
|
||||
private createLocalApi(): API {
|
||||
const api: API = {};
|
||||
for (const fn of this.config.openGenerativeUI?.sandboxFunctions ?? []) {
|
||||
api[fn.name] = fn.handler;
|
||||
}
|
||||
return api;
|
||||
}
|
||||
|
||||
private createPreviewSandbox(state: OpenGenerativeUIRenderState): void {
|
||||
const container = this.containerRef()?.nativeElement;
|
||||
if (
|
||||
!container ||
|
||||
state.fullHtml ||
|
||||
!state.hasPreview ||
|
||||
this.previewSandbox
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
void this.loadWebsandbox()
|
||||
.then((Websandbox) => {
|
||||
if (
|
||||
!this.containerRef() ||
|
||||
this.previewSandbox ||
|
||||
this.renderState().fullHtml
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sandbox = Websandbox.create(
|
||||
{},
|
||||
{
|
||||
frameContainer: container,
|
||||
frameContent: "<head></head><body></body>",
|
||||
allowAdditionalAttributes: "",
|
||||
},
|
||||
);
|
||||
this.previewSandbox = sandbox;
|
||||
sandbox.iframe.setAttribute(
|
||||
"data-testid",
|
||||
"open-generative-ui-preview-sandbox",
|
||||
);
|
||||
this.styleIframe(sandbox.iframe);
|
||||
|
||||
sandbox.promise.then(() => {
|
||||
if (this.previewSandbox !== sandbox) return;
|
||||
this.previewReady = true;
|
||||
void sandbox.run(`
|
||||
var s = document.createElement('style');
|
||||
s.textContent = 'html, body { overflow: hidden !important; }';
|
||||
document.head.appendChild(s);
|
||||
`);
|
||||
this.updatePreviewSandbox(this.renderState());
|
||||
});
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error(
|
||||
"[OpenGenerativeUI] Failed to load sandbox module:",
|
||||
error,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private updatePreviewSandbox(state: OpenGenerativeUIRenderState): void {
|
||||
if (!this.previewSandbox || !this.previewReady) return;
|
||||
|
||||
const headParts: string[] = [];
|
||||
if (state.css) headParts.push(`<style>${state.css}</style>`);
|
||||
if (state.previewStyles) headParts.push(state.previewStyles);
|
||||
if (headParts.length) {
|
||||
void this.previewSandbox.run(
|
||||
`document.head.innerHTML = ${JSON.stringify(headParts.join(""))}`,
|
||||
);
|
||||
}
|
||||
if (state.previewBody) {
|
||||
void this.previewSandbox.run(
|
||||
`document.body.innerHTML = ${JSON.stringify(state.previewBody)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private reconcileSandboxState(state: OpenGenerativeUIRenderState): void {
|
||||
if (!state.fullHtml) {
|
||||
this.destroyFinalSandbox();
|
||||
this.resetFinalRuntimeState();
|
||||
this.autoHeight.set(undefined);
|
||||
|
||||
if (!state.hasPreview) {
|
||||
this.destroyPreviewSandbox();
|
||||
return;
|
||||
}
|
||||
|
||||
this.createPreviewSandbox(state);
|
||||
this.updatePreviewSandbox(state);
|
||||
return;
|
||||
}
|
||||
|
||||
this.destroyPreviewSandbox();
|
||||
const finalSandboxKey = this.getFinalSandboxKey(state.fullHtml, state.css);
|
||||
if (!this.sandbox || this.finalSandboxKey !== finalSandboxKey) {
|
||||
void this.createFinalSandbox(state.fullHtml, state.css, finalSandboxKey);
|
||||
}
|
||||
|
||||
this.injectJsFunctions(state.jsFunctions);
|
||||
void this.executeNewExpressions(state.jsExpressions);
|
||||
}
|
||||
|
||||
private getFinalSandboxKey(
|
||||
fullHtml: string,
|
||||
css: string | undefined,
|
||||
): string {
|
||||
return JSON.stringify([fullHtml, css ?? ""]);
|
||||
}
|
||||
|
||||
private async createFinalSandbox(
|
||||
fullHtml: string,
|
||||
css: string | undefined,
|
||||
finalSandboxKey: string,
|
||||
): Promise<void> {
|
||||
const container = this.containerRef()?.nativeElement;
|
||||
if (!container || !fullHtml) return;
|
||||
|
||||
this.destroyPreviewSandbox();
|
||||
this.destroyFinalSandbox();
|
||||
this.finalSandboxKey = finalSandboxKey;
|
||||
this.resetFinalRuntimeState();
|
||||
this.autoHeight.set(undefined);
|
||||
|
||||
const htmlContent = css ? injectCssIntoHtml(fullHtml, css) : fullHtml;
|
||||
|
||||
try {
|
||||
const Websandbox = await this.loadWebsandbox();
|
||||
if (!this.containerRef() || this.finalSandboxKey !== finalSandboxKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sandbox = Websandbox.create(this.createLocalApi(), {
|
||||
frameContainer: container,
|
||||
frameContent: ensureHead(htmlContent),
|
||||
allowAdditionalAttributes: "",
|
||||
});
|
||||
this.sandbox = sandbox;
|
||||
sandbox.iframe.setAttribute(
|
||||
"data-testid",
|
||||
"open-generative-ui-final-sandbox",
|
||||
);
|
||||
this.styleIframe(sandbox.iframe);
|
||||
|
||||
sandbox.promise.then(() => {
|
||||
if (this.sandbox !== sandbox) return;
|
||||
this.sandboxReady = true;
|
||||
void sandbox.run(`
|
||||
var s = document.createElement('style');
|
||||
s.textContent = 'html, body { overflow: hidden !important; }';
|
||||
document.head.appendChild(s);
|
||||
`);
|
||||
const queue = this.pendingQueue;
|
||||
this.pendingQueue = [];
|
||||
for (const code of queue) void sandbox.run(code);
|
||||
});
|
||||
} catch (error) {
|
||||
if (this.finalSandboxKey === finalSandboxKey) {
|
||||
this.finalSandboxKey = undefined;
|
||||
}
|
||||
console.error("[OpenGenerativeUI] Failed to load sandbox module:", error);
|
||||
}
|
||||
}
|
||||
|
||||
private resetFinalRuntimeState(): void {
|
||||
this.executedExpressionIndex = 0;
|
||||
this.jsFunctionsInjected = false;
|
||||
this.heightMeasured = false;
|
||||
this.pendingQueue = [];
|
||||
this.sandboxReady = false;
|
||||
}
|
||||
|
||||
private injectJsFunctions(jsFunctions: string | undefined): void {
|
||||
if (!jsFunctions || this.jsFunctionsInjected) return;
|
||||
this.jsFunctionsInjected = true;
|
||||
this.runOrQueue(jsFunctions);
|
||||
}
|
||||
|
||||
private async executeNewExpressions(
|
||||
expressions: string[] | undefined,
|
||||
): Promise<void> {
|
||||
if (!expressions?.length) return;
|
||||
|
||||
const startIndex = this.executedExpressionIndex;
|
||||
if (startIndex >= expressions.length) return;
|
||||
const newExpressions = expressions.slice(startIndex);
|
||||
this.executedExpressionIndex = expressions.length;
|
||||
|
||||
if (this.sandboxReady && this.sandbox) {
|
||||
for (const expression of newExpressions) {
|
||||
await this.sandbox.run(expression);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.pendingQueue.push(...newExpressions);
|
||||
}
|
||||
|
||||
private runOrQueue(code: string): void {
|
||||
if (this.sandboxReady && this.sandbox) {
|
||||
void this.sandbox.run(code);
|
||||
return;
|
||||
}
|
||||
this.pendingQueue.push(code);
|
||||
}
|
||||
|
||||
private measureFinalHeight(): (() => void) | undefined {
|
||||
const sandbox = this.sandbox;
|
||||
if (!sandbox) return undefined;
|
||||
|
||||
let handled = false;
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
if (handled) return;
|
||||
if (
|
||||
event.source === sandbox.iframe.contentWindow &&
|
||||
event.data?.type === "__ck_resize"
|
||||
) {
|
||||
handled = true;
|
||||
this.autoHeight.set(event.data.height);
|
||||
window.removeEventListener("message", onMessage);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", onMessage);
|
||||
|
||||
const measureOnce = `
|
||||
(function() {
|
||||
var s = document.createElement('style');
|
||||
s.textContent = 'body { height: auto !important; min-height: 0 !important; }';
|
||||
document.head.appendChild(s);
|
||||
var h = document.body.scrollHeight;
|
||||
var cs = getComputedStyle(document.body);
|
||||
h += parseFloat(cs.marginTop) || 0;
|
||||
h += parseFloat(cs.marginBottom) || 0;
|
||||
s.remove();
|
||||
parent.postMessage({ type: "__ck_resize", height: Math.ceil(h) }, "*");
|
||||
})();
|
||||
`;
|
||||
|
||||
this.runOrQueue(measureOnce);
|
||||
return () => window.removeEventListener("message", onMessage);
|
||||
}
|
||||
|
||||
private styleIframe(iframe: HTMLIFrameElement): void {
|
||||
iframe.style.width = "100%";
|
||||
iframe.style.height = "100%";
|
||||
iframe.style.border = "none";
|
||||
iframe.style.backgroundColor = "transparent";
|
||||
}
|
||||
|
||||
private destroyPreviewSandbox(): void {
|
||||
if (!this.previewSandbox) return;
|
||||
this.previewSandbox.destroy();
|
||||
this.previewSandbox = undefined;
|
||||
this.previewReady = false;
|
||||
}
|
||||
|
||||
private destroyFinalSandbox(): void {
|
||||
if (this.sandbox) {
|
||||
this.sandbox.destroy();
|
||||
this.sandbox = undefined;
|
||||
}
|
||||
this.sandboxReady = false;
|
||||
this.heightMeasured = false;
|
||||
this.finalSandboxKey = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: "copilot-open-generative-ui-activity-renderer",
|
||||
imports: [CopilotOpenGenerativeUIRenderer],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<copilot-open-generative-ui-renderer [content]="content()" />
|
||||
`,
|
||||
})
|
||||
export class CopilotOpenGenerativeUIActivityRenderer implements ActivityRenderer<OpenGenerativeUIContent> {
|
||||
readonly activityType = input.required<string>();
|
||||
readonly content = input.required<OpenGenerativeUIContent>();
|
||||
readonly message = input.required<ActivityMessage>();
|
||||
readonly agent = input<AbstractAgent | undefined>();
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import type Websandbox from "@jetbrains/websandbox";
|
||||
import {
|
||||
OpenGenerativeUIContentSchema,
|
||||
type OpenGenerativeUIContent,
|
||||
} from "../../open-generative-ui";
|
||||
|
||||
export type WebsandboxConstructor = typeof Websandbox;
|
||||
export type WebsandboxInstance = InstanceType<WebsandboxConstructor>;
|
||||
|
||||
export type WebsandboxModuleShape = {
|
||||
default:
|
||||
| WebsandboxConstructor
|
||||
| {
|
||||
default: WebsandboxConstructor;
|
||||
};
|
||||
};
|
||||
|
||||
export function resolveWebsandboxConstructor(
|
||||
module: WebsandboxModuleShape,
|
||||
): WebsandboxConstructor {
|
||||
const defaultExport = module.default;
|
||||
return "default" in defaultExport ? defaultExport.default : defaultExport;
|
||||
}
|
||||
|
||||
export function hasRenderableOpenGenerativeUIContent(
|
||||
content: OpenGenerativeUIContent | undefined,
|
||||
): boolean {
|
||||
if (!content) return false;
|
||||
const hasHtml = !!content.html?.join("").trim();
|
||||
const hasFinalHtml = content.htmlComplete && hasHtml;
|
||||
const hasPreviewHtml =
|
||||
content.cssComplete && !content.htmlComplete && hasHtml;
|
||||
return !!hasFinalHtml || !!hasPreviewHtml;
|
||||
}
|
||||
|
||||
export function shouldFlushOpenGenerativeUIImmediately(
|
||||
previous: OpenGenerativeUIContent | undefined,
|
||||
next: OpenGenerativeUIContent,
|
||||
): boolean {
|
||||
if (
|
||||
hasRenderableOpenGenerativeUIContent(previous) &&
|
||||
!hasRenderableOpenGenerativeUIContent(next)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (next.cssComplete && !previous?.cssComplete) return true;
|
||||
if (next.htmlComplete) return true;
|
||||
if (next.generating === false) return true;
|
||||
if (next.jsFunctions && !previous?.jsFunctions) return true;
|
||||
if (
|
||||
(next.jsExpressions?.length ?? 0) > (previous?.jsExpressions?.length ?? 0)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (next.html?.length && !previous?.html?.length) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function parseOpenGenerativeUIContent(
|
||||
content: unknown,
|
||||
): OpenGenerativeUIContent {
|
||||
const parsed = OpenGenerativeUIContentSchema.safeParse(content);
|
||||
return parsed.success ? parsed.data : {};
|
||||
}
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
DestroyRef,
|
||||
computed,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
} from "@angular/core";
|
||||
import type { AngularToolCall, ToolRenderer } from "../../tools";
|
||||
import type { GenerateSandboxedUiArgs } from "../../open-generative-ui";
|
||||
|
||||
@Component({
|
||||
selector: "copilot-open-generative-ui-tool-renderer",
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
@if (visibleMessage(); as message) {
|
||||
<div
|
||||
data-testid="open-generative-ui-tool-placeholder"
|
||||
class="copilot-open-generative-ui-placeholder"
|
||||
>
|
||||
{{ message }}
|
||||
</div>
|
||||
}
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.copilot-open-generative-ui-placeholder {
|
||||
padding: 8px 12px;
|
||||
color: #999;
|
||||
font-size: 14px;
|
||||
}
|
||||
`,
|
||||
],
|
||||
})
|
||||
export class CopilotOpenGenerativeUIToolRenderer implements ToolRenderer<GenerateSandboxedUiArgs> {
|
||||
readonly toolCall =
|
||||
input.required<AngularToolCall<GenerateSandboxedUiArgs>>();
|
||||
|
||||
private readonly visibleMessageIndex = signal(0);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private previousMessageCount = 0;
|
||||
private interval: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
protected readonly visibleMessage = computed(() => {
|
||||
const call = this.toolCall();
|
||||
if (call.status === "complete") return undefined;
|
||||
|
||||
const messages = call.args.placeholderMessages;
|
||||
if (!messages?.length) return undefined;
|
||||
|
||||
return messages[this.visibleMessageIndex()] ?? messages[0];
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.destroyRef.onDestroy(() => this.clearTimer());
|
||||
|
||||
effect((onCleanup) => {
|
||||
const call = this.toolCall();
|
||||
const messages = call.args.placeholderMessages;
|
||||
this.clearTimer();
|
||||
|
||||
if (!messages?.length) {
|
||||
this.previousMessageCount = 0;
|
||||
this.visibleMessageIndex.set(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (messages.length !== this.previousMessageCount) {
|
||||
this.previousMessageCount = messages.length;
|
||||
this.visibleMessageIndex.set(messages.length - 1);
|
||||
}
|
||||
|
||||
if (call.status === "complete") return;
|
||||
|
||||
this.interval = setInterval(() => {
|
||||
this.visibleMessageIndex.update(
|
||||
(index) => (index + 1) % messages.length,
|
||||
);
|
||||
}, 5000);
|
||||
|
||||
onCleanup(() => this.clearTimer());
|
||||
});
|
||||
}
|
||||
|
||||
private clearTimer(): void {
|
||||
if (this.interval === undefined) return;
|
||||
clearInterval(this.interval);
|
||||
this.interval = undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Extracts all complete `<style>` blocks from raw HTML.
|
||||
*/
|
||||
export function extractCompleteStyles(html: string): string {
|
||||
const matches = html.match(/<style\b[^>]*>[\s\S]*?<\/style>/gi);
|
||||
return matches ? matches.join("") : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes accumulated HTML into a preview-safe body fragment.
|
||||
*/
|
||||
export function processPartialHtml(html: string): string {
|
||||
let result = html;
|
||||
|
||||
result = result.replace(/<[^>]*$/, "");
|
||||
result = result.replace(/<(style|script|head)\b[^>]*>[\s\S]*?<\/\1>/gi, "");
|
||||
result = result.replace(/<(style|script|head)\b[^>]*>[\s\S]*$/gi, "");
|
||||
result = result.replace(/&[a-zA-Z0-9#]*$/, "");
|
||||
|
||||
const bodyMatch = result.match(/<body[^>]*>([\s\S]*)/i);
|
||||
if (bodyMatch) {
|
||||
result = bodyMatch[1]!;
|
||||
result = result.replace(/<\/body>[\s\S]*/i, "");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function ensureHead(html: string): string {
|
||||
if (/<head[\s>]/i.test(html)) return html;
|
||||
return `<head></head>${html}`;
|
||||
}
|
||||
|
||||
export function injectCssIntoHtml(html: string, css: string): string {
|
||||
const headCloseIdx = html.indexOf("</head>");
|
||||
if (headCloseIdx !== -1) {
|
||||
return (
|
||||
html.slice(0, headCloseIdx) +
|
||||
`<style>${css}</style>` +
|
||||
html.slice(headCloseIdx)
|
||||
);
|
||||
}
|
||||
return `<head><style>${css}</style></head>${html}`;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { inject, InjectionToken, Provider } from "@angular/core";
|
||||
import { AbstractAgent } from "@ag-ui/client";
|
||||
import {
|
||||
ClientTool,
|
||||
FrontendToolConfig,
|
||||
HumanInTheLoopConfig,
|
||||
RenderToolCallConfig,
|
||||
} from "./tools";
|
||||
import { LICENSE_WATERMARK_ENABLED } from "./license-watermark";
|
||||
import type { RenderActivityMessageConfig } from "./activity-renderer";
|
||||
import type { SuggestionsConfig } from "@copilotkit/core";
|
||||
import type { OpenGenerativeUIConfig } from "./open-generative-ui";
|
||||
import type {
|
||||
Catalog,
|
||||
LitComponentImplementation,
|
||||
LitRenderable,
|
||||
Theme as A2UITheme,
|
||||
} from "@copilotkit/a2ui-renderer/web-components";
|
||||
|
||||
export interface A2UIConfig {
|
||||
theme?: A2UITheme;
|
||||
catalog?: Catalog<LitComponentImplementation>;
|
||||
loadingComponent?: () => LitRenderable;
|
||||
includeSchema?: boolean;
|
||||
}
|
||||
|
||||
export interface CopilotKitConfig {
|
||||
runtimeUrl?: string;
|
||||
headers?: Record<string, string>;
|
||||
licenseKey?: string;
|
||||
properties?: Record<string, unknown>;
|
||||
agents?: Record<string, AbstractAgent>;
|
||||
selfManagedAgents?: Record<string, AbstractAgent>;
|
||||
tools?: ClientTool[];
|
||||
renderToolCalls?: RenderToolCallConfig[];
|
||||
renderActivityMessages?: RenderActivityMessageConfig[];
|
||||
suggestionsConfig?: SuggestionsConfig[];
|
||||
frontendTools?: FrontendToolConfig[];
|
||||
humanInTheLoop?: HumanInTheLoopConfig[];
|
||||
a2ui?: A2UIConfig;
|
||||
openGenerativeUI?: OpenGenerativeUIConfig;
|
||||
}
|
||||
|
||||
const COPILOT_CLOUD_PUBLIC_API_KEY_HEADER = "X-CopilotCloud-Public-Api-Key";
|
||||
const COPILOT_CLOUD_PUBLIC_API_KEY_REGEX = /^ck_pub_[0-9a-f]{32}$/i;
|
||||
const LICENSE_WATERMARK_LOG_FLAG = "__copilotkitAngularLicenseWatermarkLogged";
|
||||
|
||||
type ResolvedLicense = {
|
||||
key?: string;
|
||||
valid: boolean;
|
||||
warning?: string;
|
||||
};
|
||||
|
||||
function logLicenseWatermarkWarning(message: string): void {
|
||||
const globalWindow = globalThis as typeof globalThis & {
|
||||
[LICENSE_WATERMARK_LOG_FLAG]?: boolean;
|
||||
};
|
||||
if (globalWindow[LICENSE_WATERMARK_LOG_FLAG]) {
|
||||
return;
|
||||
}
|
||||
globalWindow[LICENSE_WATERMARK_LOG_FLAG] = true;
|
||||
|
||||
console.warn(
|
||||
[
|
||||
"========================================",
|
||||
"[CopilotKit] License Required",
|
||||
message,
|
||||
"Get your CopilotCloud license key and add it as `licenseKey` to remove this watermark.",
|
||||
"========================================",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
function resolveLicense(config: CopilotKitConfig): ResolvedLicense {
|
||||
const headerKey = config.headers?.[COPILOT_CLOUD_PUBLIC_API_KEY_HEADER];
|
||||
const key = config.licenseKey ?? headerKey;
|
||||
|
||||
if (!key) {
|
||||
return {
|
||||
valid: false,
|
||||
warning:
|
||||
"No CopilotCloud license key was found. A watermark will be shown until one is added.",
|
||||
};
|
||||
}
|
||||
|
||||
if (!COPILOT_CLOUD_PUBLIC_API_KEY_REGEX.test(key)) {
|
||||
return {
|
||||
key,
|
||||
valid: false,
|
||||
warning:
|
||||
"Your CopilotCloud license key appears invalid. A watermark will be shown until a valid key is added.",
|
||||
};
|
||||
}
|
||||
|
||||
return { key, valid: true };
|
||||
}
|
||||
|
||||
export const COPILOT_KIT_CONFIG = new InjectionToken<CopilotKitConfig>(
|
||||
"COPILOT_KIT_CONFIG",
|
||||
);
|
||||
|
||||
export function injectCopilotKitConfig(): CopilotKitConfig {
|
||||
return inject(COPILOT_KIT_CONFIG);
|
||||
}
|
||||
|
||||
export function provideCopilotKit(config: CopilotKitConfig): Provider {
|
||||
const resolvedLicense = resolveLicense(config);
|
||||
const headers = config.headers ?? {};
|
||||
if (
|
||||
LICENSE_WATERMARK_ENABLED &&
|
||||
!resolvedLicense.valid &&
|
||||
resolvedLicense.warning
|
||||
) {
|
||||
logLicenseWatermarkWarning(resolvedLicense.warning);
|
||||
}
|
||||
|
||||
const mergedHeaders = headers[COPILOT_CLOUD_PUBLIC_API_KEY_HEADER]
|
||||
? headers
|
||||
: !resolvedLicense.valid || !resolvedLicense.key
|
||||
? headers
|
||||
: {
|
||||
...headers,
|
||||
[COPILOT_CLOUD_PUBLIC_API_KEY_HEADER]: resolvedLicense.key,
|
||||
};
|
||||
|
||||
return {
|
||||
provide: COPILOT_KIT_CONFIG,
|
||||
useValue: {
|
||||
...config,
|
||||
headers: mergedHeaders,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
import { Component, Injector, signal } from "@angular/core";
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { CopilotKit } from "./copilotkit";
|
||||
import { provideCopilotKit } from "./config";
|
||||
import { HumanInTheLoop } from "./human-in-the-loop";
|
||||
import {
|
||||
GENERATE_SANDBOXED_UI_TOOL_NAME,
|
||||
OPEN_GENERATIVE_UI_ACTIVITY_TYPE,
|
||||
} from "./open-generative-ui";
|
||||
import { CopilotOpenGenerativeUIActivityRenderer } from "./components/open-generative-ui/open-generative-ui-activity-renderer";
|
||||
import { CopilotOpenGenerativeUIToolRenderer } from "./components/open-generative-ui/open-generative-ui-tool-renderer";
|
||||
import { CopilotA2UIActivityRenderer } from "./components/a2ui/a2ui-activity-renderer";
|
||||
import { CopilotA2UIToolRenderer } from "./components/a2ui/a2ui-tool-renderer";
|
||||
import {
|
||||
AGUI_SEND_STATE_SNAPSHOT_TOOL_NAME,
|
||||
RENDER_A2UI_TOOL_NAME,
|
||||
} from "./components/a2ui/a2ui-tool-types";
|
||||
import { A2UI_SCHEMA_CONTEXT_DESCRIPTION } from "@copilotkit/a2ui-renderer/web-components";
|
||||
import {
|
||||
A2UI_DEFAULT_DESIGN_GUIDELINES,
|
||||
A2UI_DEFAULT_GENERATION_GUIDELINES,
|
||||
} from "@copilotkit/shared";
|
||||
|
||||
const mockSubscribe = vi.fn();
|
||||
const mockAddTool = vi.fn();
|
||||
const mockRemoveTool = vi.fn();
|
||||
const mockSetRuntimeUrl = vi.fn();
|
||||
const mockSetRuntimeTransport = vi.fn();
|
||||
const mockSetHeaders = vi.fn();
|
||||
const mockSetProperties = vi.fn();
|
||||
const mockSetAgents = vi.fn();
|
||||
const mockGetAgent = vi.fn();
|
||||
const mockGetTool = vi.fn();
|
||||
const mockAddContext = vi.fn();
|
||||
const mockRemoveContext = vi.fn();
|
||||
|
||||
const licenseKey = "ck_pub_" + "a".repeat(32);
|
||||
|
||||
let lastCoreInstance: any;
|
||||
let lastCoreConfig: any;
|
||||
|
||||
vi.mock("@copilotkit/core", () => {
|
||||
const CopilotKitCoreRuntimeConnectionStatus = {
|
||||
Disconnected: "disconnected",
|
||||
Connected: "connected",
|
||||
Connecting: "connecting",
|
||||
Error: "error",
|
||||
} as const;
|
||||
|
||||
class MockCopilotKitCore {
|
||||
readonly subscribe = mockSubscribe;
|
||||
readonly addTool = mockAddTool;
|
||||
readonly removeTool = mockRemoveTool;
|
||||
readonly setRuntimeUrl = mockSetRuntimeUrl;
|
||||
readonly setRuntimeTransport = mockSetRuntimeTransport;
|
||||
readonly setHeaders = mockSetHeaders;
|
||||
readonly setProperties = mockSetProperties;
|
||||
readonly setAgents__unsafe_dev_only = mockSetAgents;
|
||||
readonly getAgent = mockGetAgent;
|
||||
readonly getTool = mockGetTool;
|
||||
readonly addContext = mockAddContext;
|
||||
readonly removeContext = mockRemoveContext;
|
||||
agents: Record<string, any> = {};
|
||||
runtimeUrl = undefined;
|
||||
runtimeTransport = "auto";
|
||||
headers: Record<string, string> = {};
|
||||
a2uiEnabled = false;
|
||||
openGenerativeUIEnabled = false;
|
||||
runtimeConnectionStatus =
|
||||
CopilotKitCoreRuntimeConnectionStatus.Disconnected;
|
||||
listener?: Parameters<typeof mockSubscribe>[0];
|
||||
|
||||
constructor(config: any) {
|
||||
lastCoreConfig = config;
|
||||
lastCoreInstance = this;
|
||||
mockSubscribe.mockImplementationOnce((listener: any) => {
|
||||
this.listener = listener;
|
||||
return { unsubscribe: vi.fn() };
|
||||
});
|
||||
mockAddContext.mockImplementation(
|
||||
() => `ctx-${mockAddContext.mock.calls.length}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
CopilotKitCore: MockCopilotKitCore,
|
||||
CopilotKitCoreRuntimeConnectionStatus,
|
||||
} as any;
|
||||
});
|
||||
|
||||
describe("CopilotKit", () => {
|
||||
beforeEach(() => {
|
||||
TestBed.resetTestingModule();
|
||||
vi.clearAllMocks();
|
||||
document.getElementById("copilotkit-license-watermark")?.remove();
|
||||
(globalThis as any).__copilotkitAngularLicenseWatermarkLogged = undefined;
|
||||
});
|
||||
|
||||
it("initialises core with transformed tool and renderer config", () => {
|
||||
@Component({
|
||||
selector: "dummy-tool",
|
||||
template: "",
|
||||
})
|
||||
class DummyToolComponent {
|
||||
toolCall = signal({} as any);
|
||||
}
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideCopilotKit({
|
||||
runtimeUrl: "https://runtime.local",
|
||||
headers: { Authorization: "token" },
|
||||
properties: { region: "eu" },
|
||||
licenseKey,
|
||||
tools: [
|
||||
{
|
||||
name: "search",
|
||||
description: "Search something",
|
||||
parameters: z.object({ query: z.string() }),
|
||||
handler: async () => "done",
|
||||
renderer: DummyToolComponent,
|
||||
},
|
||||
],
|
||||
renderToolCalls: [
|
||||
{
|
||||
name: "custom",
|
||||
args: z.object({ query: z.string() }),
|
||||
component: DummyToolComponent,
|
||||
agentId: "agent-a",
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const copilotKit = TestBed.inject(CopilotKit);
|
||||
|
||||
expect(lastCoreConfig.runtimeUrl).toBe("https://runtime.local");
|
||||
expect(lastCoreConfig.headers).toEqual({
|
||||
Authorization: "token",
|
||||
"X-CopilotCloud-Public-Api-Key": licenseKey,
|
||||
});
|
||||
expect(lastCoreConfig.tools).toEqual([
|
||||
expect.objectContaining({
|
||||
name: "search",
|
||||
description: "Search something",
|
||||
parameters: expect.anything(),
|
||||
handler: expect.any(Function),
|
||||
renderer: DummyToolComponent,
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(copilotKit.toolCallRenderConfigs()).toEqual([
|
||||
{
|
||||
name: "custom",
|
||||
args: expect.anything(),
|
||||
component: DummyToolComponent,
|
||||
agentId: "agent-a",
|
||||
},
|
||||
{
|
||||
name: "search",
|
||||
args: expect.anything(),
|
||||
component: DummyToolComponent,
|
||||
agentId: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("tracks client tools and executes handlers within injection context", async () => {
|
||||
const handlerSpy = vi.fn().mockResolvedValue("handled");
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideCopilotKit({ licenseKey })],
|
||||
});
|
||||
|
||||
const copilotKit = TestBed.inject(CopilotKit);
|
||||
const injector = TestBed.inject(Injector);
|
||||
|
||||
copilotKit.addFrontendTool({
|
||||
name: "client",
|
||||
description: "Client tool",
|
||||
args: z.object({ value: z.string() }),
|
||||
component: class {
|
||||
toolCall = signal({} as any);
|
||||
},
|
||||
handler: handlerSpy,
|
||||
injector,
|
||||
});
|
||||
|
||||
expect(mockAddTool).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "client" }),
|
||||
);
|
||||
expect(copilotKit.clientToolCallRenderConfigs()).toHaveLength(1);
|
||||
|
||||
const tool = mockAddTool.mock.calls.at(-1)![0];
|
||||
const mockAgent = { agentId: "test-agent" };
|
||||
const mockContext = {
|
||||
toolCall: { id: "call-1", function: { name: "client" } },
|
||||
agent: mockAgent,
|
||||
};
|
||||
await tool.handler({ value: "ok" }, mockContext);
|
||||
expect(handlerSpy).toHaveBeenCalledWith({ value: "ok" }, mockContext);
|
||||
});
|
||||
|
||||
it("registers human-in-the-loop tools and delegates responses", async () => {
|
||||
const onResultSpy = vi
|
||||
.spyOn(HumanInTheLoop.prototype, "onResult")
|
||||
.mockResolvedValue("result");
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideCopilotKit({ licenseKey })],
|
||||
});
|
||||
|
||||
const copilotKit = TestBed.inject(CopilotKit);
|
||||
|
||||
const toolConfig = {
|
||||
name: "approval",
|
||||
args: z.object({ summary: z.string() }),
|
||||
component: class {
|
||||
toolCall = signal({} as any);
|
||||
},
|
||||
toolCall: vi.fn(),
|
||||
agentId: "agent-1",
|
||||
} as const;
|
||||
|
||||
copilotKit.addHumanInTheLoop(toolConfig);
|
||||
|
||||
expect(copilotKit.humanInTheLoopToolRenderConfigs()).toEqual([toolConfig]);
|
||||
expect(mockAddTool).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "approval" }),
|
||||
);
|
||||
|
||||
const tool = mockAddTool.mock.calls.at(-1)![0];
|
||||
const mockAgent = { agentId: "agent-1" };
|
||||
await tool.handler(
|
||||
{},
|
||||
{
|
||||
toolCall: { id: "call-1", function: { name: "approval" } },
|
||||
agent: mockAgent,
|
||||
},
|
||||
);
|
||||
expect(onResultSpy).toHaveBeenCalledWith("call-1", "approval");
|
||||
|
||||
onResultSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("registers Open Generative UI tool, activity renderer, and contexts", async () => {
|
||||
const sandboxHandler = vi.fn().mockResolvedValue("dark");
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideCopilotKit({
|
||||
licenseKey,
|
||||
openGenerativeUI: {
|
||||
designSkill: "Use compact dashboard styling.",
|
||||
sandboxFunctions: [
|
||||
{
|
||||
name: "setTheme",
|
||||
description: "Set the active theme",
|
||||
parameters: z.object({ theme: z.string() }),
|
||||
handler: sandboxHandler,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const copilotKit = TestBed.inject(CopilotKit);
|
||||
|
||||
expect(mockAddTool).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: GENERATE_SANDBOXED_UI_TOOL_NAME,
|
||||
component: CopilotOpenGenerativeUIToolRenderer,
|
||||
followUp: true,
|
||||
}),
|
||||
);
|
||||
expect(copilotKit.clientToolCallRenderConfigs()).toEqual([
|
||||
expect.objectContaining({
|
||||
name: GENERATE_SANDBOXED_UI_TOOL_NAME,
|
||||
component: CopilotOpenGenerativeUIToolRenderer,
|
||||
followUp: true,
|
||||
}),
|
||||
]);
|
||||
expect(copilotKit.activityMessageRenderConfigs()).toEqual([
|
||||
expect.objectContaining({
|
||||
activityType: OPEN_GENERATIVE_UI_ACTIVITY_TYPE,
|
||||
component: CopilotOpenGenerativeUIActivityRenderer,
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(mockAddContext).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
description:
|
||||
"Design guidelines for the generateSandboxedUi tool. Follow these when building UI.",
|
||||
value: "Use compact dashboard styling.",
|
||||
}),
|
||||
);
|
||||
expect(mockAddContext).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
description:
|
||||
"Sandbox functions available in generated sandboxed UI code. Call via: await Websandbox.connection.remote.<functionName>(args)",
|
||||
value: expect.stringContaining('"setTheme"'),
|
||||
}),
|
||||
);
|
||||
|
||||
const tool = mockAddTool.mock.calls.at(-1)![0];
|
||||
const result = await tool.handler(
|
||||
{ initialHeight: 200 },
|
||||
{
|
||||
toolCall: {
|
||||
id: "call-1",
|
||||
function: { name: GENERATE_SANDBOXED_UI_TOOL_NAME },
|
||||
},
|
||||
agent: { agentId: "agent-1" },
|
||||
},
|
||||
);
|
||||
expect(result).toBe("UI generated");
|
||||
});
|
||||
|
||||
it("enables built-in A2UI renderers and contexts from runtime capability", () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideCopilotKit({ licenseKey })],
|
||||
});
|
||||
|
||||
const copilotKit = TestBed.inject(CopilotKit);
|
||||
const core = lastCoreInstance!;
|
||||
|
||||
expect(copilotKit.activityMessageRenderConfigs()).toEqual([]);
|
||||
expect(copilotKit.toolCallRenderConfigs()).toEqual([]);
|
||||
|
||||
core.a2uiEnabled = true;
|
||||
core.listener!.onRuntimeConnectionStatusChanged({
|
||||
status: "connected",
|
||||
});
|
||||
|
||||
expect(copilotKit.activityMessageRenderConfigs()).toEqual([
|
||||
expect.objectContaining({
|
||||
activityType: "a2ui-surface",
|
||||
component: CopilotA2UIActivityRenderer,
|
||||
}),
|
||||
]);
|
||||
expect(copilotKit.toolCallRenderConfigs()).toEqual([
|
||||
expect.objectContaining({
|
||||
name: RENDER_A2UI_TOOL_NAME,
|
||||
component: CopilotA2UIToolRenderer,
|
||||
passAgent: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: AGUI_SEND_STATE_SNAPSHOT_TOOL_NAME,
|
||||
component: CopilotA2UIToolRenderer,
|
||||
passAgent: true,
|
||||
}),
|
||||
]);
|
||||
expect(mockAddContext).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
description:
|
||||
"A2UI catalog capabilities: available catalog IDs and custom component definitions the client can render.",
|
||||
value: expect.stringContaining("Available A2UI catalog:"),
|
||||
}),
|
||||
);
|
||||
expect(mockAddContext).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
description: A2UI_SCHEMA_CONTEXT_DESCRIPTION,
|
||||
value: expect.stringContaining('"catalogId"'),
|
||||
}),
|
||||
);
|
||||
expect(mockAddContext).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
description:
|
||||
"A2UI generation guidelines — protocol rules, tool arguments, path rules, data model format, and form/two-way-binding instructions.",
|
||||
value: A2UI_DEFAULT_GENERATION_GUIDELINES,
|
||||
}),
|
||||
);
|
||||
expect(mockAddContext).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
description:
|
||||
"A2UI design guidelines — visual design rules, component hierarchy tips, and action handler patterns.",
|
||||
value: A2UI_DEFAULT_DESIGN_GUIDELINES,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("removes tools and renderer configs", () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideCopilotKit({ licenseKey })],
|
||||
});
|
||||
|
||||
const copilotKit = TestBed.inject(CopilotKit);
|
||||
|
||||
copilotKit.addRenderToolCall({
|
||||
name: "temp",
|
||||
args: z.object({}),
|
||||
component: class {
|
||||
toolCall = signal({} as any);
|
||||
},
|
||||
agentId: undefined,
|
||||
});
|
||||
|
||||
copilotKit.removeTool("temp");
|
||||
|
||||
expect(mockRemoveTool).toHaveBeenCalledWith("temp", undefined);
|
||||
expect(copilotKit.toolCallRenderConfigs()).toEqual([]);
|
||||
});
|
||||
|
||||
it("updates runtime configuration via core methods", () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideCopilotKit({ licenseKey })],
|
||||
});
|
||||
|
||||
const copilotKit = TestBed.inject(CopilotKit);
|
||||
|
||||
copilotKit.updateRuntime({
|
||||
runtimeUrl: "https://other",
|
||||
runtimeTransport: "single",
|
||||
headers: { Authorization: "different" },
|
||||
properties: { locale: "en" },
|
||||
agents: { a: {} as any },
|
||||
});
|
||||
|
||||
expect(mockSetRuntimeUrl).toHaveBeenCalledWith("https://other");
|
||||
expect(mockSetRuntimeTransport).toHaveBeenCalledWith("single");
|
||||
expect(mockSetHeaders).toHaveBeenCalledWith({ Authorization: "different" });
|
||||
expect(mockSetProperties).toHaveBeenCalledWith({ locale: "en" });
|
||||
expect(mockSetAgents).toHaveBeenCalledWith({ a: {} });
|
||||
});
|
||||
|
||||
it("reflects agent updates from core subscriptions", () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideCopilotKit({ licenseKey })],
|
||||
});
|
||||
|
||||
const copilotKit = TestBed.inject(CopilotKit);
|
||||
const core = lastCoreInstance!;
|
||||
|
||||
core.agents = {
|
||||
agent1: { id: "agent1" },
|
||||
} as any;
|
||||
|
||||
core.listener!.onAgentsChanged();
|
||||
expect(copilotKit.agents()).toEqual(core.agents);
|
||||
});
|
||||
|
||||
it("does not add a watermark when license key is missing (watermark disabled)", () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideCopilotKit({ runtimeUrl: "https://runtime.local" })],
|
||||
});
|
||||
|
||||
TestBed.inject(CopilotKit);
|
||||
|
||||
expect(document.getElementById("copilotkit-license-watermark")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,592 @@
|
||||
import { AbstractAgent } from "@ag-ui/client";
|
||||
import {
|
||||
FrontendTool,
|
||||
CopilotKitCore,
|
||||
CopilotKitCoreRuntimeConnectionStatus,
|
||||
CopilotRuntimeTransport,
|
||||
type CopilotKitCoreGetSuggestionsResult,
|
||||
type IntelligenceRuntimeInfo,
|
||||
type RuntimeLicenseStatus,
|
||||
type SuggestionsConfig,
|
||||
type ThreadEndpointRuntimeInfo,
|
||||
} from "@copilotkit/core";
|
||||
import {
|
||||
Injectable,
|
||||
Injector,
|
||||
Signal,
|
||||
WritableSignal,
|
||||
computed,
|
||||
runInInjectionContext,
|
||||
signal,
|
||||
inject,
|
||||
} from "@angular/core";
|
||||
import {
|
||||
FrontendToolConfig,
|
||||
HumanInTheLoopConfig,
|
||||
RenderToolCallConfig,
|
||||
} from "./tools";
|
||||
import {
|
||||
A2UI_DEFAULT_DESIGN_GUIDELINES,
|
||||
A2UI_DEFAULT_GENERATION_GUIDELINES,
|
||||
schemaToJsonSchema,
|
||||
} from "@copilotkit/shared";
|
||||
import {
|
||||
A2UI_SCHEMA_CONTEXT_DESCRIPTION,
|
||||
buildCatalogContextValue,
|
||||
extractCatalogComponentSchemas,
|
||||
} from "@copilotkit/a2ui-renderer/web-components";
|
||||
import {
|
||||
RenderActivityMessageConfig,
|
||||
anyActivityContentSchema,
|
||||
} from "./activity-renderer";
|
||||
import { injectCopilotKitConfig } from "./config";
|
||||
import { HumanInTheLoop } from "./human-in-the-loop";
|
||||
import { ensureLicenseWatermark } from "./license-watermark";
|
||||
import { CopilotA2UIActivityRenderer } from "./components/a2ui/a2ui-activity-renderer";
|
||||
import { CopilotA2UIToolRenderer } from "./components/a2ui/a2ui-tool-renderer";
|
||||
import {
|
||||
AGUI_SEND_STATE_SNAPSHOT_TOOL_NAME,
|
||||
RENDER_A2UI_TOOL_NAME,
|
||||
RenderA2UIArgsSchema,
|
||||
} from "./components/a2ui/a2ui-tool-types";
|
||||
import {
|
||||
DEFAULT_OPEN_GENERATIVE_UI_DESIGN_SKILL,
|
||||
GENERATE_SANDBOXED_UI_DESCRIPTION,
|
||||
GENERATE_SANDBOXED_UI_TOOL_NAME,
|
||||
GenerateSandboxedUiArgsSchema,
|
||||
OPEN_GENERATIVE_UI_ACTIVITY_TYPE,
|
||||
type GenerateSandboxedUiArgs,
|
||||
} from "./open-generative-ui";
|
||||
import { CopilotOpenGenerativeUIActivityRenderer } from "./components/open-generative-ui/open-generative-ui-activity-renderer";
|
||||
import { CopilotOpenGenerativeUIToolRenderer } from "./components/open-generative-ui/open-generative-ui-tool-renderer";
|
||||
import { standardSchemaZodToJsonSchema } from "./standard-schema-zod";
|
||||
|
||||
@Injectable({ providedIn: "root" })
|
||||
export class CopilotKit {
|
||||
readonly #config = injectCopilotKitConfig();
|
||||
readonly #hitl = inject(HumanInTheLoop);
|
||||
readonly #rootInjector = inject(Injector);
|
||||
readonly #agents = signal<Record<string, AbstractAgent>>(
|
||||
this.#config.agents ?? {},
|
||||
);
|
||||
readonly agents = this.#agents.asReadonly();
|
||||
readonly #runtimeConnectionStatus =
|
||||
signal<CopilotKitCoreRuntimeConnectionStatus>(
|
||||
CopilotKitCoreRuntimeConnectionStatus.Disconnected,
|
||||
);
|
||||
readonly runtimeConnectionStatus = this.#runtimeConnectionStatus.asReadonly();
|
||||
readonly #runtimeUrl = signal<string | undefined>(undefined);
|
||||
readonly runtimeUrl = this.#runtimeUrl.asReadonly();
|
||||
readonly #runtimeTransport = signal<CopilotRuntimeTransport>("auto");
|
||||
readonly runtimeTransport = this.#runtimeTransport.asReadonly();
|
||||
readonly #headers = signal<Record<string, string>>({});
|
||||
readonly headers = this.#headers.asReadonly();
|
||||
readonly #threadEndpoints = signal<ThreadEndpointRuntimeInfo | undefined>(
|
||||
undefined,
|
||||
);
|
||||
/**
|
||||
* Thread-endpoint capability advertised by the connected runtime's `/info`
|
||||
* response, or `undefined` before the runtime reports `Connected`. Exposed
|
||||
* as a signal (rather than a plain `core.threadEndpoints` read) so reactive
|
||||
* consumers re-run when `/info` lands.
|
||||
*/
|
||||
readonly threadEndpoints = this.#threadEndpoints.asReadonly();
|
||||
readonly #intelligence = signal<IntelligenceRuntimeInfo | undefined>(
|
||||
undefined,
|
||||
);
|
||||
/**
|
||||
* Intelligence runtime info advertised by the connected runtime's `/info`
|
||||
* response, or `undefined` before the runtime reports `Connected`. Carries
|
||||
* the realtime `wsUrl`. Exposed as a signal so reactive consumers re-run
|
||||
* when `/info` populates it — even if the connection status does not
|
||||
* transition in the same turn.
|
||||
*/
|
||||
readonly intelligence = this.#intelligence.asReadonly();
|
||||
readonly #licenseStatus = signal<RuntimeLicenseStatus | undefined>(undefined);
|
||||
/**
|
||||
* Server-reported license status from the connected runtime's `/info`
|
||||
* response, or `undefined` before the runtime reports it. Exposed as a signal
|
||||
* (rather than a plain `core.licenseStatus` read) so reactive consumers — e.g.
|
||||
* the threads drawer's license gate — re-run once the status resolves.
|
||||
*/
|
||||
readonly licenseStatus = this.#licenseStatus.asReadonly();
|
||||
readonly #suggestionsByAgent = signal<
|
||||
Record<string, CopilotKitCoreGetSuggestionsResult>
|
||||
>({});
|
||||
readonly suggestionsByAgent = this.#suggestionsByAgent.asReadonly();
|
||||
|
||||
readonly core = new CopilotKitCore({
|
||||
runtimeUrl: this.#config.runtimeUrl,
|
||||
headers: this.#config.headers,
|
||||
properties: this.#config.properties,
|
||||
agents__unsafe_dev_only: {
|
||||
...this.#config.agents,
|
||||
...this.#config.selfManagedAgents,
|
||||
},
|
||||
tools: this.#config.tools,
|
||||
suggestionsConfig: this.#config.suggestionsConfig,
|
||||
});
|
||||
|
||||
readonly #toolCallRenderConfigs: WritableSignal<RenderToolCallConfig[]> =
|
||||
signal([]);
|
||||
readonly #builtInToolCallRenderConfigs: WritableSignal<
|
||||
RenderToolCallConfig[]
|
||||
> = signal([]);
|
||||
readonly #clientToolCallRenderConfigs: WritableSignal<FrontendToolConfig[]> =
|
||||
signal([]);
|
||||
readonly #builtInClientToolCallRenderConfigs: WritableSignal<
|
||||
FrontendToolConfig[]
|
||||
> = signal([]);
|
||||
readonly #humanInTheLoopToolRenderConfigs: WritableSignal<
|
||||
HumanInTheLoopConfig[]
|
||||
> = signal([]);
|
||||
readonly #activityMessageRenderConfigs: WritableSignal<
|
||||
RenderActivityMessageConfig[]
|
||||
> = signal([]);
|
||||
readonly #builtInActivityMessageRenderConfigs: WritableSignal<
|
||||
RenderActivityMessageConfig[]
|
||||
> = signal([]);
|
||||
|
||||
readonly toolCallRenderConfigs: Signal<RenderToolCallConfig[]> = computed(
|
||||
() => [
|
||||
...this.#toolCallRenderConfigs(),
|
||||
...this.#builtInToolCallRenderConfigs(),
|
||||
],
|
||||
);
|
||||
readonly clientToolCallRenderConfigs: Signal<FrontendToolConfig[]> = computed(
|
||||
() => [
|
||||
...this.#clientToolCallRenderConfigs(),
|
||||
...this.#builtInClientToolCallRenderConfigs(),
|
||||
],
|
||||
);
|
||||
readonly humanInTheLoopToolRenderConfigs: Signal<HumanInTheLoopConfig[]> =
|
||||
this.#humanInTheLoopToolRenderConfigs.asReadonly();
|
||||
readonly activityMessageRenderConfigs: Signal<RenderActivityMessageConfig[]> =
|
||||
computed(() => [
|
||||
...this.#activityMessageRenderConfigs(),
|
||||
...this.#builtInActivityMessageRenderConfigs(),
|
||||
]);
|
||||
|
||||
#openGenerativeUIToolRegistered = false;
|
||||
#openGenerativeUIContextIds: string[] = [];
|
||||
#a2UIContextIds: string[] = [];
|
||||
|
||||
constructor() {
|
||||
ensureLicenseWatermark(this.#config.headers);
|
||||
|
||||
this.#runtimeConnectionStatus.set(this.core.runtimeConnectionStatus);
|
||||
this.#runtimeUrl.set(this.core.runtimeUrl);
|
||||
this.#runtimeTransport.set(this.core.runtimeTransport);
|
||||
this.#headers.set(this.core.headers);
|
||||
this.#threadEndpoints.set(this.core.threadEndpoints);
|
||||
this.#intelligence.set(this.core.intelligence);
|
||||
this.#licenseStatus.set(this.core.licenseStatus);
|
||||
this.#config.renderToolCalls?.forEach((renderConfig) => {
|
||||
this.addRenderToolCall(renderConfig);
|
||||
});
|
||||
this.#config.renderActivityMessages?.forEach((renderConfig) => {
|
||||
this.addRenderActivityMessage(renderConfig);
|
||||
});
|
||||
|
||||
this.#config.tools?.forEach((tool) => {
|
||||
if (tool.renderer && tool.parameters) {
|
||||
this.addRenderToolCall({
|
||||
name: tool.name,
|
||||
args: tool.parameters,
|
||||
component: tool.renderer,
|
||||
agentId: tool.agentId,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.#config.frontendTools?.forEach((clientTool) => {
|
||||
this.addFrontendTool({ ...clientTool, injector: this.#rootInjector });
|
||||
});
|
||||
|
||||
this.#config.humanInTheLoop?.forEach((humanInTheLoopTool) => {
|
||||
this.addHumanInTheLoop(humanInTheLoopTool);
|
||||
});
|
||||
|
||||
this.core.subscribe({
|
||||
onAgentsChanged: () => {
|
||||
this.#agents.set(this.core.agents);
|
||||
},
|
||||
onRuntimeConnectionStatusChanged: ({ status }) => {
|
||||
// Core assigns `threadEndpoints`/`intelligence` synchronously before it
|
||||
// notifies this callback (see agent-registry `ensureRuntimeMode`), so
|
||||
// mirroring them here keeps the signals in lockstep with the status
|
||||
// signal and lets reactive consumers observe `intelligence.wsUrl` once
|
||||
// `/info` resolves.
|
||||
this.#runtimeConnectionStatus.set(status);
|
||||
this.#threadEndpoints.set(this.core.threadEndpoints);
|
||||
this.#intelligence.set(this.core.intelligence);
|
||||
this.#licenseStatus.set(this.core.licenseStatus);
|
||||
this.#syncBuiltInActivityMessageRenderers();
|
||||
this.#syncBuiltInOpenGenerativeUI();
|
||||
},
|
||||
onHeadersChanged: ({ headers }) => {
|
||||
this.#headers.set(headers);
|
||||
},
|
||||
onSuggestionsChanged: ({ agentId, suggestions }) => {
|
||||
this.#setSuggestions(agentId, {
|
||||
suggestions,
|
||||
isLoading: this.core.getSuggestions(agentId).isLoading,
|
||||
});
|
||||
},
|
||||
onSuggestionsStartedLoading: ({ agentId }) => {
|
||||
this.#setSuggestions(agentId, {
|
||||
suggestions: this.core.getSuggestions(agentId).suggestions,
|
||||
isLoading: true,
|
||||
});
|
||||
},
|
||||
onSuggestionsFinishedLoading: ({ agentId }) => {
|
||||
this.#setSuggestions(agentId, {
|
||||
suggestions: this.core.getSuggestions(agentId).suggestions,
|
||||
isLoading: false,
|
||||
});
|
||||
},
|
||||
});
|
||||
this.#syncBuiltInActivityMessageRenderers();
|
||||
this.#syncBuiltInOpenGenerativeUI();
|
||||
}
|
||||
|
||||
#setSuggestions(
|
||||
agentId: string,
|
||||
result: CopilotKitCoreGetSuggestionsResult,
|
||||
): void {
|
||||
this.#suggestionsByAgent.update((current) => ({
|
||||
...current,
|
||||
[agentId]: result,
|
||||
}));
|
||||
}
|
||||
|
||||
#bindClientTool(
|
||||
clientToolWithInjector: FrontendToolConfig & {
|
||||
injector: Injector;
|
||||
},
|
||||
): FrontendTool {
|
||||
const { injector, handler, ...frontendCandidate } = clientToolWithInjector;
|
||||
|
||||
return {
|
||||
...frontendCandidate,
|
||||
handler: (args, context) =>
|
||||
runInInjectionContext(injector, () => handler(args, context)),
|
||||
};
|
||||
}
|
||||
|
||||
addFrontendTool(
|
||||
clientToolWithInjector: FrontendToolConfig & {
|
||||
injector: Injector;
|
||||
},
|
||||
): void {
|
||||
const tool = this.#bindClientTool(clientToolWithInjector);
|
||||
|
||||
this.core.addTool(tool);
|
||||
|
||||
this.#clientToolCallRenderConfigs.update((current) => [
|
||||
...current,
|
||||
clientToolWithInjector,
|
||||
]);
|
||||
}
|
||||
|
||||
addRenderToolCall(renderConfig: RenderToolCallConfig): void {
|
||||
this.#toolCallRenderConfigs.update((current) => [...current, renderConfig]);
|
||||
}
|
||||
|
||||
addRenderActivityMessage(renderConfig: RenderActivityMessageConfig): void {
|
||||
this.#activityMessageRenderConfigs.update((current) => [
|
||||
...current,
|
||||
renderConfig,
|
||||
]);
|
||||
}
|
||||
|
||||
#syncBuiltInActivityMessageRenderers(): void {
|
||||
const renderers: RenderActivityMessageConfig[] = [];
|
||||
|
||||
if (this.core.a2uiEnabled) {
|
||||
renderers.push({
|
||||
activityType: "a2ui-surface",
|
||||
content: anyActivityContentSchema,
|
||||
component: CopilotA2UIActivityRenderer,
|
||||
});
|
||||
}
|
||||
|
||||
if (this.#isOpenGenerativeUIActive()) {
|
||||
renderers.push({
|
||||
activityType: OPEN_GENERATIVE_UI_ACTIVITY_TYPE,
|
||||
content: anyActivityContentSchema,
|
||||
component: CopilotOpenGenerativeUIActivityRenderer,
|
||||
});
|
||||
}
|
||||
|
||||
this.#builtInActivityMessageRenderConfigs.set(renderers);
|
||||
this.#syncBuiltInA2UI();
|
||||
}
|
||||
|
||||
#syncBuiltInA2UI(): void {
|
||||
if (!this.core.a2uiEnabled) {
|
||||
this.#builtInToolCallRenderConfigs.set([]);
|
||||
this.#removeA2UIContexts();
|
||||
return;
|
||||
}
|
||||
|
||||
this.#builtInToolCallRenderConfigs.set([
|
||||
{
|
||||
name: RENDER_A2UI_TOOL_NAME,
|
||||
args: RenderA2UIArgsSchema,
|
||||
component: CopilotA2UIToolRenderer,
|
||||
passAgent: true,
|
||||
},
|
||||
{
|
||||
name: AGUI_SEND_STATE_SNAPSHOT_TOOL_NAME,
|
||||
args: RenderA2UIArgsSchema,
|
||||
component: CopilotA2UIToolRenderer,
|
||||
passAgent: true,
|
||||
},
|
||||
]);
|
||||
this.#syncA2UIContexts();
|
||||
}
|
||||
|
||||
#getA2UICatalog(): unknown {
|
||||
return this.#config.a2ui?.catalog;
|
||||
}
|
||||
|
||||
#syncA2UIContexts(): void {
|
||||
this.#removeA2UIContexts();
|
||||
|
||||
const catalog = this.#getA2UICatalog();
|
||||
this.#a2UIContextIds.push(
|
||||
this.core.addContext({
|
||||
description:
|
||||
"A2UI catalog capabilities: available catalog IDs and custom component definitions the client can render.",
|
||||
value: buildCatalogContextValue(catalog),
|
||||
}),
|
||||
);
|
||||
|
||||
if (this.#config.a2ui?.includeSchema === false) return;
|
||||
|
||||
this.#a2UIContextIds.push(
|
||||
this.core.addContext({
|
||||
description: A2UI_SCHEMA_CONTEXT_DESCRIPTION,
|
||||
value: JSON.stringify(extractCatalogComponentSchemas(catalog)),
|
||||
}),
|
||||
);
|
||||
this.#a2UIContextIds.push(
|
||||
this.core.addContext({
|
||||
description:
|
||||
"A2UI generation guidelines — protocol rules, tool arguments, path rules, data model format, and form/two-way-binding instructions.",
|
||||
value: A2UI_DEFAULT_GENERATION_GUIDELINES,
|
||||
}),
|
||||
);
|
||||
this.#a2UIContextIds.push(
|
||||
this.core.addContext({
|
||||
description:
|
||||
"A2UI design guidelines — visual design rules, component hierarchy tips, and action handler patterns.",
|
||||
value: A2UI_DEFAULT_DESIGN_GUIDELINES,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
#removeA2UIContexts(): void {
|
||||
for (const id of this.#a2UIContextIds) {
|
||||
this.core.removeContext(id);
|
||||
}
|
||||
this.#a2UIContextIds = [];
|
||||
}
|
||||
|
||||
#isOpenGenerativeUIActive(): boolean {
|
||||
return !!this.#config.openGenerativeUI || this.core.openGenerativeUIEnabled;
|
||||
}
|
||||
|
||||
#createOpenGenerativeUITool(): FrontendToolConfig<GenerateSandboxedUiArgs> & {
|
||||
injector: Injector;
|
||||
} {
|
||||
return {
|
||||
name: GENERATE_SANDBOXED_UI_TOOL_NAME,
|
||||
description: GENERATE_SANDBOXED_UI_DESCRIPTION,
|
||||
parameters: GenerateSandboxedUiArgsSchema,
|
||||
component: CopilotOpenGenerativeUIToolRenderer,
|
||||
handler: async () => "UI generated",
|
||||
followUp: true,
|
||||
injector: this.#rootInjector,
|
||||
};
|
||||
}
|
||||
|
||||
#syncBuiltInOpenGenerativeUI(): void {
|
||||
const active = this.#isOpenGenerativeUIActive();
|
||||
|
||||
if (!active) {
|
||||
this.#builtInClientToolCallRenderConfigs.set([]);
|
||||
this.#removeOpenGenerativeUIContexts();
|
||||
if (this.#openGenerativeUIToolRegistered) {
|
||||
this.core.removeTool(GENERATE_SANDBOXED_UI_TOOL_NAME);
|
||||
this.#openGenerativeUIToolRegistered = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const builtInTool = this.#createOpenGenerativeUITool();
|
||||
this.#builtInClientToolCallRenderConfigs.set([builtInTool]);
|
||||
|
||||
if (
|
||||
!this.#openGenerativeUIToolRegistered &&
|
||||
!this.core.getTool({ toolName: GENERATE_SANDBOXED_UI_TOOL_NAME })
|
||||
) {
|
||||
this.core.addTool(this.#bindClientTool(builtInTool));
|
||||
this.#openGenerativeUIToolRegistered = true;
|
||||
}
|
||||
|
||||
this.#syncOpenGenerativeUIContexts();
|
||||
}
|
||||
|
||||
#syncOpenGenerativeUIContexts(): void {
|
||||
this.#removeOpenGenerativeUIContexts();
|
||||
|
||||
const designSkill =
|
||||
this.#config.openGenerativeUI?.designSkill ??
|
||||
DEFAULT_OPEN_GENERATIVE_UI_DESIGN_SKILL;
|
||||
this.#openGenerativeUIContextIds.push(
|
||||
this.core.addContext({
|
||||
description:
|
||||
"Design guidelines for the generateSandboxedUi tool. Follow these when building UI.",
|
||||
value: designSkill,
|
||||
}),
|
||||
);
|
||||
|
||||
const sandboxFunctions =
|
||||
this.#config.openGenerativeUI?.sandboxFunctions ?? [];
|
||||
if (!sandboxFunctions.length) return;
|
||||
|
||||
const descriptors = JSON.stringify(
|
||||
sandboxFunctions.map((fn) => ({
|
||||
name: fn.name,
|
||||
description: fn.description,
|
||||
parameters: schemaToJsonSchema(fn.parameters, {
|
||||
zodToJsonSchema: standardSchemaZodToJsonSchema,
|
||||
}),
|
||||
})),
|
||||
);
|
||||
|
||||
this.#openGenerativeUIContextIds.push(
|
||||
this.core.addContext({
|
||||
description:
|
||||
"Sandbox functions available in generated sandboxed UI code. Call via: await Websandbox.connection.remote.<functionName>(args)",
|
||||
value: descriptors,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
#removeOpenGenerativeUIContexts(): void {
|
||||
for (const id of this.#openGenerativeUIContextIds) {
|
||||
this.core.removeContext(id);
|
||||
}
|
||||
this.#openGenerativeUIContextIds = [];
|
||||
}
|
||||
|
||||
#bindHumanInTheLoopTool(
|
||||
humanInTheLoopTool: HumanInTheLoopConfig,
|
||||
): FrontendTool {
|
||||
return {
|
||||
...humanInTheLoopTool,
|
||||
handler: (args, { toolCall }) => {
|
||||
return this.#hitl.onResult(toolCall.id, humanInTheLoopTool.name);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
addHumanInTheLoop(humanInTheLoopTool: HumanInTheLoopConfig): void {
|
||||
this.#humanInTheLoopToolRenderConfigs.update((current) => [
|
||||
...current,
|
||||
humanInTheLoopTool,
|
||||
]);
|
||||
|
||||
const tool = this.#bindHumanInTheLoopTool(humanInTheLoopTool);
|
||||
|
||||
this.core.addTool(tool);
|
||||
}
|
||||
|
||||
addSuggestionsConfig(config: SuggestionsConfig): string {
|
||||
return this.core.addSuggestionsConfig(config);
|
||||
}
|
||||
|
||||
removeSuggestionsConfig(id: string): void {
|
||||
this.core.removeSuggestionsConfig(id);
|
||||
}
|
||||
|
||||
reloadSuggestions(agentId: string): void {
|
||||
this.core.reloadSuggestions(agentId);
|
||||
}
|
||||
|
||||
clearSuggestions(agentId: string): void {
|
||||
this.core.clearSuggestions(agentId);
|
||||
}
|
||||
|
||||
getSuggestions(agentId: string): CopilotKitCoreGetSuggestionsResult {
|
||||
return (
|
||||
this.#suggestionsByAgent()[agentId] ?? this.core.getSuggestions(agentId)
|
||||
);
|
||||
}
|
||||
|
||||
#isSameAgentId<T extends { agentId?: string }>(
|
||||
target: T,
|
||||
agentId?: string,
|
||||
): boolean {
|
||||
if (agentId) {
|
||||
return target.agentId === agentId;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
removeTool(toolName: string, agentId?: string): void {
|
||||
this.core.removeTool(toolName, agentId);
|
||||
const keep = (config: { name: string; agentId?: string }) =>
|
||||
config.name !== toolName ||
|
||||
(agentId === undefined
|
||||
? !!config.agentId
|
||||
: !this.#isSameAgentId(config, agentId));
|
||||
this.#clientToolCallRenderConfigs.update((current) => current.filter(keep));
|
||||
this.#humanInTheLoopToolRenderConfigs.update((current) =>
|
||||
current.filter(keep),
|
||||
);
|
||||
this.#toolCallRenderConfigs.update((current) => current.filter(keep));
|
||||
}
|
||||
|
||||
getAgent(agentId: string): AbstractAgent | undefined {
|
||||
return this.core.getAgent(agentId);
|
||||
}
|
||||
|
||||
updateRuntime(options: {
|
||||
runtimeUrl?: string;
|
||||
runtimeTransport?: CopilotRuntimeTransport;
|
||||
headers?: Record<string, string>;
|
||||
properties?: Record<string, unknown>;
|
||||
agents?: Record<string, AbstractAgent>;
|
||||
selfManagedAgents?: Record<string, AbstractAgent>;
|
||||
}): void {
|
||||
if (options.runtimeUrl !== undefined) {
|
||||
this.core.setRuntimeUrl(options.runtimeUrl);
|
||||
this.#runtimeUrl.set(options.runtimeUrl);
|
||||
}
|
||||
if (options.runtimeTransport !== undefined) {
|
||||
this.core.setRuntimeTransport(options.runtimeTransport);
|
||||
this.#runtimeTransport.set(options.runtimeTransport);
|
||||
}
|
||||
if (options.headers !== undefined) {
|
||||
this.core.setHeaders(options.headers);
|
||||
this.#headers.set(options.headers);
|
||||
}
|
||||
if (options.properties !== undefined) {
|
||||
this.core.setProperties(options.properties);
|
||||
}
|
||||
if (
|
||||
options.agents !== undefined ||
|
||||
options.selfManagedAgents !== undefined
|
||||
) {
|
||||
this.core.setAgents__unsafe_dev_only({
|
||||
...(options.agents ?? this.#config.agents),
|
||||
...(options.selfManagedAgents ?? this.#config.selfManagedAgents),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
import { Component, signal } from "@angular/core";
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { CopilotKitAgentContext } from "../copilotkit-agent-context";
|
||||
import { CopilotKit } from "../../copilotkit";
|
||||
|
||||
class CopilotKitCoreStub {
|
||||
addContext = vi.fn(() => "ctx-1");
|
||||
removeContext = vi.fn();
|
||||
}
|
||||
|
||||
class CopilotKitStub {
|
||||
core = new CopilotKitCoreStub();
|
||||
}
|
||||
|
||||
describe("CopilotKitAgentContext", () => {
|
||||
beforeEach(() => {
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [{ provide: CopilotKit, useClass: CopilotKitStub }],
|
||||
});
|
||||
});
|
||||
|
||||
it("adds and removes context for separate description/value inputs", () => {
|
||||
@Component({
|
||||
imports: [CopilotKitAgentContext],
|
||||
template: `
|
||||
<div copilotkitAgentContext [description]="description()" [value]="value"></div>
|
||||
`,
|
||||
})
|
||||
class HostComponent {
|
||||
description = signal("Initial");
|
||||
value = { foo: "bar" };
|
||||
}
|
||||
|
||||
const fixture = TestBed.createComponent(HostComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
const core = TestBed.inject(CopilotKit).core as CopilotKitCoreStub;
|
||||
expect(core.addContext).toHaveBeenCalledWith({
|
||||
description: "Initial",
|
||||
value: { foo: "bar" },
|
||||
});
|
||||
|
||||
fixture.componentInstance.description.set("Updated");
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(core.removeContext).toHaveBeenCalledWith("ctx-1");
|
||||
expect(core.addContext).toHaveBeenLastCalledWith({
|
||||
description: "Updated",
|
||||
value: { foo: "bar" },
|
||||
});
|
||||
|
||||
fixture.destroy();
|
||||
expect(core.removeContext).toHaveBeenLastCalledWith("ctx-1");
|
||||
});
|
||||
|
||||
it("supports passing full context object via directive binding", () => {
|
||||
@Component({
|
||||
imports: [CopilotKitAgentContext],
|
||||
template: `
|
||||
<div [copilotkitAgentContext]="context"></div>
|
||||
`,
|
||||
})
|
||||
class ObjectHostComponent {
|
||||
context = { description: "All", value: 42 };
|
||||
}
|
||||
|
||||
const fixture = TestBed.createComponent(ObjectHostComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
const core = TestBed.inject(CopilotKit).core as CopilotKitCoreStub;
|
||||
expect(core.addContext).toHaveBeenCalledWith({
|
||||
description: "All",
|
||||
value: 42,
|
||||
});
|
||||
});
|
||||
});
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import { Component } from "@angular/core";
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
COPILOT_CHAT_DEFAULT_LABELS,
|
||||
injectChatLabels,
|
||||
provideCopilotChatLabels,
|
||||
} from "../../chat-config";
|
||||
|
||||
describe("Copilot chat labels", () => {
|
||||
it("returns default labels when no provider is registered", () => {
|
||||
@Component({ standalone: true, template: "" })
|
||||
class HostComponent {
|
||||
labels = injectChatLabels();
|
||||
}
|
||||
|
||||
TestBed.configureTestingModule({});
|
||||
const fixture = TestBed.createComponent(HostComponent);
|
||||
|
||||
expect(fixture.componentInstance.labels).toEqual(
|
||||
COPILOT_CHAT_DEFAULT_LABELS,
|
||||
);
|
||||
});
|
||||
|
||||
it("merges provided labels with defaults", () => {
|
||||
@Component({ standalone: true, template: "" })
|
||||
class HostComponent {
|
||||
labels = injectChatLabels();
|
||||
}
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideCopilotChatLabels({
|
||||
chatInputPlaceholder: "Override",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(HostComponent);
|
||||
expect(fixture.componentInstance.labels.chatInputPlaceholder).toBe(
|
||||
"Override",
|
||||
);
|
||||
expect(
|
||||
fixture.componentInstance.labels.assistantMessageToolbarCopyCodeLabel,
|
||||
).toBe(COPILOT_CHAT_DEFAULT_LABELS.assistantMessageToolbarCopyCodeLabel);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Component } from "@angular/core";
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { provideCopilotKit, injectCopilotKitConfig } from "../../config";
|
||||
|
||||
describe("CopilotKit config", () => {
|
||||
beforeEach(() => {
|
||||
(globalThis as any).__copilotkitAngularLicenseWatermarkLogged = undefined;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("provides configuration via DI", () => {
|
||||
@Component({ standalone: true, template: "" })
|
||||
class HostComponent {
|
||||
config = injectCopilotKitConfig();
|
||||
}
|
||||
|
||||
const headers = {
|
||||
Authorization: "token",
|
||||
"X-CopilotCloud-Public-Api-Key": "ck_pub_" + "b".repeat(32),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideCopilotKit({
|
||||
runtimeUrl: "https://example.com",
|
||||
headers,
|
||||
licenseKey: "ck_pub_" + "a".repeat(32),
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(HostComponent);
|
||||
expect(fixture.componentInstance.config.runtimeUrl).toBe(
|
||||
"https://example.com",
|
||||
);
|
||||
expect(fixture.componentInstance.config.headers).toBe(headers);
|
||||
});
|
||||
|
||||
it("does not throw or warn when license key is missing (watermark disabled)", () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
expect(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideCopilotKit({ runtimeUrl: "https://example.com" })],
|
||||
});
|
||||
}).not.toThrow();
|
||||
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not inject invalid license key into headers", () => {
|
||||
@Component({ standalone: true, template: "" })
|
||||
class HostComponent {
|
||||
config = injectCopilotKitConfig();
|
||||
}
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideCopilotKit({
|
||||
runtimeUrl: "https://example.com",
|
||||
headers: { Authorization: "token" },
|
||||
licenseKey: "invalid-key",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(HostComponent);
|
||||
expect(fixture.componentInstance.config.headers).toEqual({
|
||||
Authorization: "token",
|
||||
});
|
||||
});
|
||||
});
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
import { Component } from "@angular/core";
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { CopilotKitFrontendTool } from "../copilotkit-frontend-tool";
|
||||
import { CopilotKit } from "../../core/copilotkit";
|
||||
import { provideCopilotKit } from "../../core/copilotkit.providers";
|
||||
import { z } from "zod";
|
||||
|
||||
// Mock CopilotKitCore
|
||||
vi.mock("@copilotkit/core", () => ({
|
||||
CopilotKitCore: vi.fn().mockImplementation(() => {
|
||||
const runtimeUrlSetter = vi.fn();
|
||||
return {
|
||||
addTool: vi.fn(),
|
||||
removeTool: vi.fn(),
|
||||
setHeaders: vi.fn(),
|
||||
setProperties: vi.fn(),
|
||||
setAgents: vi.fn(),
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
setRuntimeUrl: vi.fn((url: string | undefined) => {
|
||||
runtimeUrlSetter(url);
|
||||
}),
|
||||
get runtimeUrl() {
|
||||
return undefined;
|
||||
},
|
||||
__runtimeUrlSetter: runtimeUrlSetter,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("CopilotKitFrontendTool - Simple", () => {
|
||||
let service: CopilotKit;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideCopilotKit({})],
|
||||
});
|
||||
service = TestBed.inject(CopilotKit);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it.skip("should create directive instance", () => {
|
||||
// Cannot test direct instantiation with inject()
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it.skip("should have required inputs", () => {
|
||||
// Cannot test direct instantiation with inject()
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it.skip("should register tool on init", () => {
|
||||
// Cannot test direct instantiation with inject()
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it.skip("should unregister tool on destroy", () => {
|
||||
// Cannot test direct instantiation with inject()
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it.skip("should warn if name is missing", () => {
|
||||
// Cannot test direct instantiation with inject()
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
import { Component, signal, Type } from "@angular/core";
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
registerRenderToolCall,
|
||||
registerFrontendTool,
|
||||
registerHumanInTheLoop,
|
||||
} from "../../tools";
|
||||
import { CopilotKit } from "../../copilotkit";
|
||||
import { z } from "zod";
|
||||
|
||||
class CopilotKitStub {
|
||||
addRenderToolCall = vi.fn();
|
||||
addFrontendTool = vi.fn();
|
||||
removeTool = vi.fn();
|
||||
addHumanInTheLoop = vi.fn();
|
||||
}
|
||||
|
||||
@Component({ standalone: true, template: "", selector: "dummy-tool" })
|
||||
class DummyToolComponent {}
|
||||
|
||||
describe("tool registration helpers", () => {
|
||||
let copilotKitStub: CopilotKitStub;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.resetTestingModule();
|
||||
copilotKitStub = new CopilotKitStub();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [{ provide: CopilotKit, useValue: copilotKitStub }],
|
||||
});
|
||||
});
|
||||
|
||||
it("registers and cleans up renderers", () => {
|
||||
@Component({ standalone: true, template: "" })
|
||||
class HostComponent {
|
||||
constructor() {
|
||||
registerRenderToolCall({
|
||||
name: "tool",
|
||||
args: z.object({ value: z.string() }),
|
||||
component: DummyToolComponent as Type<any>,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const fixture = TestBed.createComponent(HostComponent);
|
||||
expect(copilotKitStub.addRenderToolCall).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "tool" }),
|
||||
);
|
||||
|
||||
fixture.destroy();
|
||||
expect(copilotKitStub.removeTool).toHaveBeenCalledWith("tool", undefined);
|
||||
});
|
||||
|
||||
it("registers client tools and removes them on destroy", async () => {
|
||||
const handler = vi.fn(async () => "handled");
|
||||
|
||||
@Component({ standalone: true, template: "" })
|
||||
class HostComponent {
|
||||
constructor() {
|
||||
registerFrontendTool({
|
||||
name: "client-tool",
|
||||
description: "",
|
||||
args: z.object({}),
|
||||
component: DummyToolComponent as Type<any>,
|
||||
handler,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const fixture = TestBed.createComponent(HostComponent);
|
||||
expect(copilotKitStub.addFrontendTool).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "client-tool" }),
|
||||
);
|
||||
|
||||
const added = copilotKitStub.addFrontendTool.mock.calls.at(-1)![0];
|
||||
await added.handler({});
|
||||
expect(handler).toHaveBeenCalled();
|
||||
|
||||
fixture.destroy();
|
||||
expect(copilotKitStub.removeTool).toHaveBeenCalledWith(
|
||||
"client-tool",
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("registers human-in-the-loop tools and removes them on destroy", () => {
|
||||
@Component({ standalone: true, template: "" })
|
||||
class HostComponent {
|
||||
constructor() {
|
||||
registerHumanInTheLoop({
|
||||
name: "approval",
|
||||
args: z.object({}),
|
||||
component: DummyToolComponent as Type<any>,
|
||||
toolCall: signal({
|
||||
args: {},
|
||||
status: "in-progress",
|
||||
result: undefined,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const fixture = TestBed.createComponent(HostComponent);
|
||||
expect(copilotKitStub.addHumanInTheLoop).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "approval" }),
|
||||
);
|
||||
|
||||
fixture.destroy();
|
||||
expect(copilotKitStub.removeTool).toHaveBeenCalledWith(
|
||||
"approval",
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { HumanInTheLoop } from "../../human-in-the-loop";
|
||||
|
||||
describe("HumanInTheLoop service", () => {
|
||||
it("resolves when matching result is provided", async () => {
|
||||
TestBed.configureTestingModule({ providers: [HumanInTheLoop] });
|
||||
const service = TestBed.inject(HumanInTheLoop);
|
||||
|
||||
const promise = service.onResult("call-1", "approval");
|
||||
service.addResult("call-1", "approval", { status: "ok" });
|
||||
|
||||
await expect(promise).resolves.toEqual({
|
||||
toolCallId: "call-1",
|
||||
toolName: "approval",
|
||||
result: { status: "ok" },
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores non-matching results until criteria matches", async () => {
|
||||
TestBed.configureTestingModule({ providers: [HumanInTheLoop] });
|
||||
const service = TestBed.inject(HumanInTheLoop);
|
||||
|
||||
const promise = service.onResult("call-2", "verify");
|
||||
|
||||
service.addResult("call-2", "other", "nope");
|
||||
|
||||
const race = Promise.race([
|
||||
promise,
|
||||
new Promise((resolve) => setTimeout(() => resolve("pending"), 20)),
|
||||
]);
|
||||
|
||||
await expect(race).resolves.toBe("pending");
|
||||
|
||||
service.addResult("call-2", "verify", "ok");
|
||||
await expect(promise).resolves.toEqual({
|
||||
toolCallId: "call-2",
|
||||
toolName: "verify",
|
||||
result: "ok",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
Directive,
|
||||
Input,
|
||||
OnInit,
|
||||
OnChanges,
|
||||
OnDestroy,
|
||||
SimpleChanges,
|
||||
Inject,
|
||||
} from "@angular/core";
|
||||
import { CopilotKit } from "../copilotkit";
|
||||
import type { Context } from "@ag-ui/client";
|
||||
|
||||
/**
|
||||
* Directive to manage agent context in CopilotKit.
|
||||
* Automatically adds context on init, updates on changes, and removes on destroy.
|
||||
*
|
||||
* @example
|
||||
* ```html
|
||||
* <!-- With separate inputs -->
|
||||
* <div copilotkitAgentContext
|
||||
* [description]="'User preferences'"
|
||||
* [value]="userSettings">
|
||||
* </div>
|
||||
*
|
||||
* <!-- With context object -->
|
||||
* <div [copilotkitAgentContext]="contextObject">
|
||||
* </div>
|
||||
*
|
||||
* <!-- With dynamic values -->
|
||||
* <div copilotkitAgentContext
|
||||
* description="Form state"
|
||||
* [value]="formData$ | async">
|
||||
* </div>
|
||||
* ```
|
||||
*/
|
||||
@Directive({
|
||||
selector: "[copilotkitAgentContext]",
|
||||
standalone: true,
|
||||
})
|
||||
export class CopilotKitAgentContext implements OnInit, OnChanges, OnDestroy {
|
||||
private contextId?: string;
|
||||
|
||||
constructor(@Inject(CopilotKit) private readonly copilotkit: CopilotKit) {}
|
||||
|
||||
/**
|
||||
* Context object containing both description and value.
|
||||
* If provided, this takes precedence over individual inputs.
|
||||
*/
|
||||
@Input("copilotkitAgentContext") context?: Context;
|
||||
|
||||
/**
|
||||
* Description of the context.
|
||||
* Used when context object is not provided.
|
||||
*/
|
||||
@Input() description?: string;
|
||||
|
||||
/**
|
||||
* Value of the context.
|
||||
* Used when context object is not provided.
|
||||
*/
|
||||
@Input() value?: any;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.addContext();
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
// Check if any relevant input has changed
|
||||
const hasContextChange = "context" in changes;
|
||||
const hasDescriptionChange = "description" in changes;
|
||||
const hasValueChange = "value" in changes;
|
||||
|
||||
if (hasContextChange || hasDescriptionChange || hasValueChange) {
|
||||
// Skip the first change as ngOnInit handles initial setup
|
||||
if (this.contextId) {
|
||||
this.updateContext();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.removeContext();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the context to CopilotKit
|
||||
*/
|
||||
private addContext(): void {
|
||||
const contextToAdd = this.getContext();
|
||||
|
||||
if (contextToAdd) {
|
||||
this.contextId = this.copilotkit.core.addContext(contextToAdd);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the context by removing the old one and adding a new one
|
||||
*/
|
||||
private updateContext(): void {
|
||||
this.removeContext();
|
||||
this.addContext();
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the current context from CopilotKit
|
||||
*/
|
||||
private removeContext(): void {
|
||||
if (this.contextId) {
|
||||
this.copilotkit.core.removeContext(this.contextId);
|
||||
this.contextId = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the context object from inputs
|
||||
*/
|
||||
private getContext(): Context | null {
|
||||
// If context object is provided, use it
|
||||
if (this.context) {
|
||||
return this.context;
|
||||
}
|
||||
|
||||
// Otherwise, build from individual inputs
|
||||
// Note: null is a valid value, but undefined means not set
|
||||
if (this.description !== undefined && this.value !== undefined) {
|
||||
return {
|
||||
description: this.description,
|
||||
value: this.value,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import {
|
||||
Directive,
|
||||
ElementRef,
|
||||
OnInit,
|
||||
OnDestroy,
|
||||
AfterViewInit,
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
} from "@angular/core";
|
||||
import { ScrollPosition } from "../scroll-position";
|
||||
import { ResizeObserverService } from "../resize-observer";
|
||||
import { Subject } from "rxjs";
|
||||
import {
|
||||
takeUntil,
|
||||
debounceTime,
|
||||
filter,
|
||||
distinctUntilChanged,
|
||||
} from "rxjs/operators";
|
||||
|
||||
export type ScrollBehavior = "smooth" | "instant" | "auto";
|
||||
|
||||
/**
|
||||
* Directive for implementing stick-to-bottom scroll behavior
|
||||
* Similar to the React use-stick-to-bottom library
|
||||
*
|
||||
* @example
|
||||
* ```html
|
||||
* <div copilotStickToBottom
|
||||
* [enabled]="true"
|
||||
* [threshold]="10"
|
||||
* [initialBehavior]="'smooth'"
|
||||
* [resizeBehavior]="'smooth'"
|
||||
* (isAtBottomChange)="onBottomChange($event)">
|
||||
* <!-- Content -->
|
||||
* </div>
|
||||
* ```
|
||||
*/
|
||||
@Directive({
|
||||
standalone: true,
|
||||
selector: "[copilotStickToBottom]",
|
||||
providers: [ScrollPosition, ResizeObserverService],
|
||||
})
|
||||
export class StickToBottom implements OnInit, AfterViewInit, OnDestroy {
|
||||
enabled = input<boolean>(true);
|
||||
threshold = input<number>(10);
|
||||
initialBehavior = input<ScrollBehavior>("smooth");
|
||||
resizeBehavior = input<ScrollBehavior>("smooth");
|
||||
debounceMs = input<number>(100);
|
||||
|
||||
isAtBottomChange = output<boolean>();
|
||||
scrollToBottomRequested = output<void>();
|
||||
|
||||
private elementRef = inject(ElementRef);
|
||||
private scrollService = inject(ScrollPosition);
|
||||
private resizeService = inject(ResizeObserverService);
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
private contentElement?: HTMLElement;
|
||||
private wasAtBottom = true;
|
||||
private hasInitialized = false;
|
||||
private userHasScrolled = false;
|
||||
|
||||
ngOnInit(): void {
|
||||
// Setup will happen in ngAfterViewInit
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
const element = this.elementRef.nativeElement as HTMLElement;
|
||||
|
||||
// Find or create content wrapper
|
||||
this.contentElement = element.querySelector(
|
||||
"[data-stick-to-bottom-content]",
|
||||
) as HTMLElement;
|
||||
if (!this.contentElement) {
|
||||
this.contentElement = element;
|
||||
}
|
||||
|
||||
this.setupScrollMonitoring();
|
||||
this.setupResizeMonitoring();
|
||||
this.setupContentMutationObserver();
|
||||
|
||||
// Initial scroll to bottom if enabled
|
||||
setTimeout(() => {
|
||||
this.hasInitialized = true;
|
||||
if (this.enabled()) {
|
||||
this.scrollToBottom(this.initialBehavior());
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
private setupScrollMonitoring(): void {
|
||||
if (!this.enabled()) return;
|
||||
|
||||
const element = this.elementRef.nativeElement;
|
||||
|
||||
// Monitor scroll position
|
||||
this.scrollService
|
||||
.monitorScrollPosition(element, this.threshold())
|
||||
.pipe(
|
||||
takeUntil(this.destroy$),
|
||||
debounceTime(this.debounceMs()),
|
||||
distinctUntilChanged((a, b) => a.isAtBottom === b.isAtBottom),
|
||||
)
|
||||
.subscribe((state) => {
|
||||
const wasAtBottom = this.wasAtBottom;
|
||||
this.wasAtBottom = state.isAtBottom;
|
||||
|
||||
// Detect user scroll
|
||||
if (!state.isAtBottom && wasAtBottom && this.hasInitialized) {
|
||||
this.userHasScrolled = true;
|
||||
} else if (state.isAtBottom) {
|
||||
this.userHasScrolled = false;
|
||||
}
|
||||
|
||||
// Emit change
|
||||
this.isAtBottomChange.emit(state.isAtBottom);
|
||||
});
|
||||
}
|
||||
|
||||
private setupResizeMonitoring(): void {
|
||||
if (!this.enabled() || !this.contentElement) return;
|
||||
|
||||
// Monitor content resize
|
||||
this.resizeService
|
||||
.observeElement(this.contentElement, 0, 250)
|
||||
.pipe(
|
||||
takeUntil(this.destroy$),
|
||||
filter(() => this.enabled() && !this.userHasScrolled),
|
||||
)
|
||||
.subscribe((state) => {
|
||||
// Auto-scroll on resize if we were at bottom
|
||||
if (this.wasAtBottom && !state.isResizing) {
|
||||
this.scrollToBottom(this.resizeBehavior());
|
||||
}
|
||||
});
|
||||
|
||||
// Monitor container resize
|
||||
const element = this.elementRef.nativeElement;
|
||||
this.resizeService
|
||||
.observeElement(element, 0, 250)
|
||||
.pipe(
|
||||
takeUntil(this.destroy$),
|
||||
filter(
|
||||
() => this.enabled() && !this.userHasScrolled && this.wasAtBottom,
|
||||
),
|
||||
)
|
||||
.subscribe(() => {
|
||||
// Adjust scroll on container resize
|
||||
this.scrollToBottom(this.resizeBehavior());
|
||||
});
|
||||
}
|
||||
|
||||
private setupContentMutationObserver(): void {
|
||||
if (!this.enabled() || !this.contentElement) return;
|
||||
|
||||
const mutationObserver = new MutationObserver(() => {
|
||||
if (this.enabled() && this.wasAtBottom && !this.userHasScrolled) {
|
||||
// Content changed, scroll to bottom if we were there
|
||||
requestAnimationFrame(() => {
|
||||
this.scrollToBottom(this.resizeBehavior());
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
mutationObserver.observe(this.contentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
});
|
||||
|
||||
// Cleanup on destroy
|
||||
this.destroy$.subscribe(() => {
|
||||
mutationObserver.disconnect();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Public method to scroll to bottom
|
||||
* Can be called from parent component
|
||||
*/
|
||||
public scrollToBottom(behavior: ScrollBehavior = "smooth"): void {
|
||||
const element = this.elementRef.nativeElement;
|
||||
const smooth = behavior === "smooth";
|
||||
|
||||
this.scrollService.scrollToBottom(element, smooth);
|
||||
this.userHasScrolled = false;
|
||||
this.scrollToBottomRequested.emit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if currently at bottom
|
||||
*/
|
||||
public isAtBottom(): boolean {
|
||||
return this.scrollService.isAtBottom(
|
||||
this.elementRef.nativeElement,
|
||||
this.threshold(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current scroll state
|
||||
*/
|
||||
public getScrollState() {
|
||||
const element = this.elementRef.nativeElement;
|
||||
return {
|
||||
scrollTop: element.scrollTop,
|
||||
scrollHeight: element.scrollHeight,
|
||||
clientHeight: element.clientHeight,
|
||||
isAtBottom: this.isAtBottom(),
|
||||
};
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import {
|
||||
Directive,
|
||||
Input,
|
||||
ElementRef,
|
||||
HostListener,
|
||||
OnDestroy,
|
||||
inject,
|
||||
ViewContainerRef,
|
||||
} from "@angular/core";
|
||||
import {
|
||||
Overlay,
|
||||
OverlayRef,
|
||||
OverlayPositionBuilder,
|
||||
ConnectedPosition,
|
||||
} from "@angular/cdk/overlay";
|
||||
import { ComponentPortal } from "@angular/cdk/portal";
|
||||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
} from "@angular/core";
|
||||
|
||||
@Component({
|
||||
selector: "copilot-tooltip-content",
|
||||
standalone: true,
|
||||
template: `
|
||||
<div class="copilot-tooltip-wrapper" [attr.data-position]="position">
|
||||
<div class="copilot-tooltip">
|
||||
{{ text }}
|
||||
</div>
|
||||
<div class="copilot-tooltip-arrow"></div>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.copilot-tooltip-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
animation: fadeIn 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.copilot-tooltip {
|
||||
background-color: #1a1a1a;
|
||||
color: white;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
max-width: 200px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.copilot-tooltip-arrow {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
/* Arrow for tooltip below element (arrow points up to tooltip) */
|
||||
.copilot-tooltip-wrapper[data-position="below"] .copilot-tooltip-arrow {
|
||||
top: -4px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-width: 0 4px 4px 4px;
|
||||
border-color: transparent transparent #1a1a1a transparent;
|
||||
}
|
||||
|
||||
/* Arrow for tooltip above element (arrow points down to element) */
|
||||
.copilot-tooltip-wrapper[data-position="above"] .copilot-tooltip-arrow {
|
||||
bottom: -4px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-width: 4px 4px 0 4px;
|
||||
border-color: #1a1a1a transparent transparent transparent;
|
||||
}
|
||||
|
||||
/* Arrow for tooltip to the left */
|
||||
.copilot-tooltip-wrapper[data-position="left"] .copilot-tooltip-arrow {
|
||||
right: -4px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border-width: 4px 0 4px 4px;
|
||||
border-color: transparent transparent transparent #1a1a1a;
|
||||
}
|
||||
|
||||
/* Arrow for tooltip to the right */
|
||||
.copilot-tooltip-wrapper[data-position="right"] .copilot-tooltip-arrow {
|
||||
left: -4px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border-width: 4px 4px 4px 0;
|
||||
border-color: transparent #1a1a1a transparent transparent;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(2px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class TooltipContent {
|
||||
text = "";
|
||||
private _position: "above" | "below" | "left" | "right" = "below";
|
||||
|
||||
get position(): "above" | "below" | "left" | "right" {
|
||||
return this._position;
|
||||
}
|
||||
|
||||
set position(value: "above" | "below" | "left" | "right") {
|
||||
this._position = value;
|
||||
this.cdr.markForCheck();
|
||||
}
|
||||
|
||||
constructor(private cdr: ChangeDetectorRef) {}
|
||||
}
|
||||
|
||||
@Directive({
|
||||
selector: "[copilotTooltip]",
|
||||
standalone: true,
|
||||
})
|
||||
export class CopilotTooltip implements OnDestroy {
|
||||
@Input("copilotTooltip") tooltipText = "";
|
||||
@Input() tooltipPosition: "above" | "below" | "left" | "right" = "below";
|
||||
@Input() tooltipDelay = 500; // milliseconds
|
||||
|
||||
private overlay = inject(Overlay);
|
||||
private overlayPositionBuilder = inject(OverlayPositionBuilder);
|
||||
private elementRef = inject(ElementRef);
|
||||
private viewContainerRef = inject(ViewContainerRef);
|
||||
|
||||
private overlayRef?: OverlayRef;
|
||||
private tooltipTimeout?: number;
|
||||
private originalTitle?: string;
|
||||
|
||||
@HostListener("mouseenter")
|
||||
onMouseEnter(): void {
|
||||
if (!this.tooltipText) return;
|
||||
|
||||
// Store and remove native title to prevent OS tooltip
|
||||
const element = this.elementRef.nativeElement;
|
||||
if (element.hasAttribute("title")) {
|
||||
this.originalTitle = element.getAttribute("title");
|
||||
element.removeAttribute("title");
|
||||
}
|
||||
|
||||
// Clear any existing timeout
|
||||
if (this.tooltipTimeout) {
|
||||
clearTimeout(this.tooltipTimeout);
|
||||
}
|
||||
|
||||
// Set timeout to show tooltip after delay
|
||||
this.tooltipTimeout = window.setTimeout(() => {
|
||||
this.show();
|
||||
}, this.tooltipDelay);
|
||||
}
|
||||
|
||||
@HostListener("mouseleave")
|
||||
onMouseLeave(): void {
|
||||
// Clear timeout if mouse leaves before tooltip shows
|
||||
if (this.tooltipTimeout) {
|
||||
clearTimeout(this.tooltipTimeout);
|
||||
this.tooltipTimeout = undefined;
|
||||
}
|
||||
|
||||
// Restore original title if it existed
|
||||
if (this.originalTitle !== undefined) {
|
||||
this.elementRef.nativeElement.setAttribute("title", this.originalTitle);
|
||||
this.originalTitle = undefined;
|
||||
}
|
||||
|
||||
// Hide tooltip if it's showing
|
||||
this.hide();
|
||||
}
|
||||
|
||||
private show(): void {
|
||||
if (this.overlayRef) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create overlay
|
||||
const positionStrategy = this.overlayPositionBuilder
|
||||
.flexibleConnectedTo(this.elementRef)
|
||||
.withPositions(this.getPositions())
|
||||
.withPush(false);
|
||||
|
||||
this.overlayRef = this.overlay.create({
|
||||
positionStrategy,
|
||||
scrollStrategy: this.overlay.scrollStrategies.close(),
|
||||
hasBackdrop: false,
|
||||
});
|
||||
|
||||
// Create component portal and attach
|
||||
const portal = new ComponentPortal(TooltipContent, this.viewContainerRef);
|
||||
const componentRef = this.overlayRef.attach(portal);
|
||||
componentRef.instance.text = this.tooltipText;
|
||||
|
||||
// Detect actual position after overlay is positioned
|
||||
setTimeout(() => {
|
||||
if (this.overlayRef && this.elementRef.nativeElement) {
|
||||
const tooltipRect =
|
||||
this.overlayRef.overlayElement.getBoundingClientRect();
|
||||
const elementRect =
|
||||
this.elementRef.nativeElement.getBoundingClientRect();
|
||||
|
||||
let actualPosition: "above" | "below" | "left" | "right" = "below";
|
||||
|
||||
// Determine actual position based on relative positions
|
||||
if (tooltipRect.bottom <= elementRect.top) {
|
||||
actualPosition = "above";
|
||||
} else if (tooltipRect.top >= elementRect.bottom) {
|
||||
actualPosition = "below";
|
||||
} else if (tooltipRect.right <= elementRect.left) {
|
||||
actualPosition = "left";
|
||||
} else if (tooltipRect.left >= elementRect.right) {
|
||||
actualPosition = "right";
|
||||
}
|
||||
|
||||
componentRef.instance.position = actualPosition;
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
|
||||
private hide(): void {
|
||||
if (this.overlayRef) {
|
||||
this.overlayRef.dispose();
|
||||
this.overlayRef = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private getPositions(): ConnectedPosition[] {
|
||||
const positions: Record<string, ConnectedPosition[]> = {
|
||||
above: [
|
||||
{
|
||||
originX: "center",
|
||||
originY: "top",
|
||||
overlayX: "center",
|
||||
overlayY: "bottom",
|
||||
offsetY: -12,
|
||||
},
|
||||
],
|
||||
below: [
|
||||
{
|
||||
originX: "center",
|
||||
originY: "bottom",
|
||||
overlayX: "center",
|
||||
overlayY: "top",
|
||||
offsetY: 12,
|
||||
},
|
||||
],
|
||||
left: [
|
||||
{
|
||||
originX: "start",
|
||||
originY: "center",
|
||||
overlayX: "end",
|
||||
overlayY: "center",
|
||||
offsetX: -12,
|
||||
},
|
||||
],
|
||||
right: [
|
||||
{
|
||||
originX: "end",
|
||||
originY: "center",
|
||||
overlayX: "start",
|
||||
overlayY: "center",
|
||||
offsetX: 12,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// Prefer below position, but use above as fallback
|
||||
const primary = positions[this.tooltipPosition] || positions.below;
|
||||
// For below position, add above as first fallback
|
||||
const fallbacks =
|
||||
this.tooltipPosition === "below"
|
||||
? [
|
||||
...(positions.above || []),
|
||||
...(positions.left || []),
|
||||
...(positions.right || []),
|
||||
]
|
||||
: Object.values(positions)
|
||||
.filter((p) => p !== primary)
|
||||
.flat();
|
||||
|
||||
return [...(primary || []), ...fallbacks];
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.tooltipTimeout) {
|
||||
clearTimeout(this.tooltipTimeout);
|
||||
}
|
||||
// Restore original title if it existed
|
||||
if (this.originalTitle !== undefined) {
|
||||
this.elementRef.nativeElement.setAttribute("title", this.originalTitle);
|
||||
}
|
||||
this.hide();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import { filter, lastValueFrom, Subject, take } from "rxjs";
|
||||
|
||||
@Injectable({ providedIn: "root" })
|
||||
export class HumanInTheLoop {
|
||||
results = new Subject<{
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
result: unknown;
|
||||
}>();
|
||||
|
||||
addResult(toolCallId: string, toolName: string, result: unknown) {
|
||||
this.results.next({ toolCallId, toolName, result });
|
||||
}
|
||||
|
||||
onResult(toolCallId: string, toolName: string): Promise<unknown> {
|
||||
return lastValueFrom(
|
||||
this.results.pipe(
|
||||
filter(
|
||||
(result) =>
|
||||
result.toolCallId === toolCallId && result.toolName === toolName,
|
||||
),
|
||||
take(1),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user