Agents posts on mobile (#9317)
* Agents streaming * Fix bug and lint * Add reasoning summaries to mobile agents * Add tool call approval UI for mobile agents * Add citations and annotations support for mobile agents Implements Phase 4 from the agents mobile plan: - WebSocket event handling for annotations control signal - CitationsList component displaying sources below agent responses - Collapsible sources section with source count - Each citation shows title, domain, and opens URL on tap - Citations persisted in post.props.annotations - Touch-optimized UI with 44pt minimum tap targets * Add stop and regenerate controls for mobile agents * Add tests * Fix tool approval buttons not updating after accept/reject - Remove optimistic WebSocket event approach that didn't work - Clear component streaming state on ENDED event to force switch to persisted data - Remove delays in streaming store cleanup (500ms and 100ms) - POST_EDITED event now properly updates UI for both accept and reject - Remove debug console.log statements - Fix ESLint issues (unused vars, nested callbacks, nested ternary) * Refactor agents code: remove unused client mixin, fix bugs, and simplify logic * Fixes * Add CLAUDE.md * Review feedback. * Review feedback inline functions * Learnings * Address review feedback: StyleSheet.create, parent mount checks, utils * Move to observables * Last style fix * Style tweaks
This commit is contained in:
parent
f243f1cd28
commit
0c675ec2db
36 changed files with 3389 additions and 2 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -123,3 +123,6 @@ libraries/**/**/.build
|
||||||
# Android sounds
|
# Android sounds
|
||||||
android/app/src/main/res/raw/*
|
android/app/src/main/res/raw/*
|
||||||
.aider*
|
.aider*
|
||||||
|
|
||||||
|
# Claude Code
|
||||||
|
.claude/settings.local.json
|
||||||
|
|
|
||||||
366
CLAUDE.md
Normal file
366
CLAUDE.md
Normal file
|
|
@ -0,0 +1,366 @@
|
||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
React Native 0.76.9 with **New Architecture disabled** (RCT_NEW_ARCH_ENABLED=0).
|
||||||
|
|
||||||
|
## Important Development Notes
|
||||||
|
- Always run the checks before committing code
|
||||||
|
- Always test any changes using the mobile mcp before calling something complete or committing code
|
||||||
|
- Never commit any planning markdown files
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
### Implementing Multi-Phase Features
|
||||||
|
When implementing complex features with multiple phases:
|
||||||
|
|
||||||
|
1. **Use subagents for each phase**: Launch a subagent (Task tool with `subagent_type: general-purpose`) to implement each phase independently
|
||||||
|
2. **Test after each phase**: Use a subagent to test the feature with mobile MCP after implementation
|
||||||
|
3. **Commit after each phase**: Create a commit for each completed and tested phase
|
||||||
|
4. **Never commit planning files**: Keep `.md` planning files (like `PLAN.md`) out of commits
|
||||||
|
|
||||||
|
### Testing with Mobile MCP
|
||||||
|
When testing features that require mobile device interaction:
|
||||||
|
- **Use subagents**: Launch a subagent to handle mobile MCP testing to avoid excessive context usage
|
||||||
|
- The subagent can launch the app, navigate, take screenshots, and verify functionality
|
||||||
|
- **Don't just verify the app launches** - actually trigger and interact with the feature being tested (e.g., send messages, click buttons, verify state changes)
|
||||||
|
|
||||||
|
## Key Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Setup
|
||||||
|
npm run pod-install # iOS CocoaPods (or pod-install-m1 for M1 Macs)
|
||||||
|
npm run ios-gems # Ruby gems for iOS (or ios-gems-m1 for M1 Macs)
|
||||||
|
|
||||||
|
# Development
|
||||||
|
npm start # Start Metro bundler
|
||||||
|
npm run ios # Run iOS in simulator
|
||||||
|
npm run android # Run Android in emulator
|
||||||
|
|
||||||
|
# TypeScript type checking
|
||||||
|
npm run tsc
|
||||||
|
|
||||||
|
# Fix linting issues
|
||||||
|
npm run fix
|
||||||
|
|
||||||
|
# E2E tests are in separate detox/ package
|
||||||
|
npm run e2e:ios # cd detox && npm run e2e:ios-test
|
||||||
|
npm run e2e:android # cd detox && npm run e2e:android-build && npm run e2e:android-test
|
||||||
|
|
||||||
|
# Builds delegate to Fastlane via shell scripts
|
||||||
|
npm run build:ios # ./scripts/build.sh ipa
|
||||||
|
npm run build:android # ./scripts/build.sh apk
|
||||||
|
|
||||||
|
# Utilities
|
||||||
|
npm run clean # Clean build artifacts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Dual Database System (WatermelonDB)
|
||||||
|
**Critical**: Two separate SQLite databases via WatermelonDB:
|
||||||
|
|
||||||
|
1. **App Database**: One global database for app state, server list (`app/database/models/app/`)
|
||||||
|
2. **Server Databases**: One database per connected server for channels, users, posts (`app/database/models/server/`)
|
||||||
|
|
||||||
|
**DatabaseManager** singleton (`app/database/manager/index.ts`) manages all instances. Data operations go through operators with transformers/handlers/comparators.
|
||||||
|
|
||||||
|
**Database directory**:
|
||||||
|
- iOS: App Group directory (shared with extensions)
|
||||||
|
- Android: `${documentDirectory}/databases/`
|
||||||
|
|
||||||
|
### Network Layer
|
||||||
|
- **NetworkManager** (`app/managers/network_manager.ts`) creates **one Client instance per server**
|
||||||
|
- Uses `@mattermost/react-native-network-client`
|
||||||
|
- **Product-specific API calls** should live in `client/rest.ts` within the product folder using the **ClientMix pattern** (see `app/products/calls/client/rest.ts` or `app/products/agents/client/rest.ts` for examples). Don't call `client.doFetch()` directly from action files.
|
||||||
|
|
||||||
|
### Actions Pattern
|
||||||
|
- **Local Actions** (`app/actions/local/`): Database-only operations
|
||||||
|
- **Remote Actions** (`app/actions/remote/`): Fetch from API → use operators to persist → return `{error}` on failure
|
||||||
|
|
||||||
|
### Query Layer
|
||||||
|
**Query layer** (`app/queries/`) provides both:
|
||||||
|
- `query*`: Returns WatermelonDB Query
|
||||||
|
- `observe*`: Returns RxJS Observable
|
||||||
|
- `get*`: Returns Promise (async)
|
||||||
|
- `prepare*`: Returns prepared records for batch operations
|
||||||
|
|
||||||
|
### State Management
|
||||||
|
**Hybrid approach**:
|
||||||
|
- **WatermelonDB**: Primary data store (persisted)
|
||||||
|
- **Ephemeral Stores** (`app/store/`): In-memory transient UI state
|
||||||
|
|
||||||
|
### Navigation
|
||||||
|
- Uses **`react-native-navigation`** v7 (not react-navigation)
|
||||||
|
- Each screen is a separate registered component
|
||||||
|
- Every independently shown/hidden component must be registered as a screen for proper lifecycle management
|
||||||
|
|
||||||
|
### Products Architecture
|
||||||
|
Modular features in `app/products/` with their own database models:
|
||||||
|
- `agents/`: AI agents
|
||||||
|
- `calls/`: Voice/video calling
|
||||||
|
- `playbooks/`: Playbooks with dedicated DB models
|
||||||
|
|
||||||
|
#### Agents Streaming Architecture
|
||||||
|
**WebSocket Events:**
|
||||||
|
- Agents use `custom_mattermost-ai_postupdate` WebSocket events for streaming (start, message updates, reasoning, tool calls, annotations, end)
|
||||||
|
- NO specific WebSocket event for tool call status changes after approval/rejection
|
||||||
|
- Updates arrive via standard `POST_EDITED` event after backend executes tools
|
||||||
|
|
||||||
|
**Critical Pattern:**
|
||||||
|
- Components must clear local streaming state on `ENDED` event to switch from ephemeral streaming data to persisted database data
|
||||||
|
- Otherwise stale streaming state prevents POST_EDITED updates from displaying
|
||||||
|
|
||||||
|
### Custom Native Modules
|
||||||
|
Located at `libraries/@mattermost/`:
|
||||||
|
- `@mattermost/rnutils` - Native utilities (orientation, notifications)
|
||||||
|
- `@mattermost/rnshare` - Share extension integration
|
||||||
|
- `@mattermost/hardware-keyboard` - External keyboard detection
|
||||||
|
- `@mattermost/secure-pdf-viewer` - Secure PDF viewing
|
||||||
|
|
||||||
|
**Native Bridge patterns** (when modifying native modules):
|
||||||
|
- iOS: `RCT_EXPORT_MODULE()`, `RCT_EXPORT_METHOD()` in Objective-C++
|
||||||
|
- Android: Extend `ReactContextBaseJavaModule`, use `@ReactMethod`
|
||||||
|
|
||||||
|
### TypeScript Path Aliases
|
||||||
|
```typescript
|
||||||
|
@actions/* → app/actions/*
|
||||||
|
@agents/* → app/products/agents/*
|
||||||
|
@calls/* → app/products/calls/*
|
||||||
|
@database/* → app/database/*
|
||||||
|
@queries/* → app/queries/*
|
||||||
|
@store → app/store/index
|
||||||
|
@share/* → share_extension/*
|
||||||
|
@typings/* → types/*
|
||||||
|
// ... (see tsconfig.json for complete list)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Share Extension
|
||||||
|
**Separate bundle** at `share_extension/` for Android system share. Shares code with main app but runs independently.
|
||||||
|
* iOS has its own native implementation for the Share Extension with Swift and SwiftUI
|
||||||
|
|
||||||
|
**Important:** Share extension on Android uses **React Navigation** (not react-native-navigation like main app).
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Test Organization
|
||||||
|
- **Jest coverage excludes** `/components/` and `/screens/` directories
|
||||||
|
- Mock database manager at `app/database/manager/__mocks__/index.ts`
|
||||||
|
- **E2E tests** in separate `detox/` package (not in main package.json)
|
||||||
|
- Add mocks to central `setup.ts` file, not individual test files
|
||||||
|
|
||||||
|
### Testing Patterns
|
||||||
|
|
||||||
|
#### Database Testing
|
||||||
|
- Tests use **real in-memory databases** (LokiJS), not mocks
|
||||||
|
- **Critical**: Set `extraLokiOptions: {autosave: false}` to prevent memory leaks from lingering timeouts
|
||||||
|
- Always clean up: `await DatabaseManager.destroyServerDatabase(serverUrl)` in `afterEach`
|
||||||
|
- Initialize database in `beforeEach`: `await DatabaseManager.init([serverUrl])`
|
||||||
|
|
||||||
|
#### Ephemeral Store Testing
|
||||||
|
- Use `jest.resetModules()` between tests to clear singleton state
|
||||||
|
- Require fresh imports after reset: `const Store = require('./store').default;`
|
||||||
|
- Pattern from `app/store/ephemeral_store.test.ts`
|
||||||
|
|
||||||
|
#### WebSocket Testing
|
||||||
|
- Mock connection objects, not the WebSocket class itself
|
||||||
|
- Create mock with methods: `onOpen`, `onClose`, `onError`, `onMessage`, `send`, `readyState`
|
||||||
|
- Use `client.open()` in tests to trigger `onOpen` callback
|
||||||
|
- Pattern from `app/client/websocket/index.test.ts`
|
||||||
|
|
||||||
|
#### Timer Testing
|
||||||
|
- **Always use**: `jest.useFakeTimers({doNotFake: ['nextTick']})` to let promises resolve
|
||||||
|
- Helper: `advanceTimers(ms)` from `test/timer_helpers.ts` advances time AND waits for promises
|
||||||
|
- Never fake `nextTick` or async operations will hang
|
||||||
|
|
||||||
|
#### Component Rendering
|
||||||
|
- Three helpers in `test/intl-test-helper.tsx`:
|
||||||
|
- `renderWithIntl`: Basic internationalization
|
||||||
|
- `renderWithIntlAndTheme`: + Theme context
|
||||||
|
- `renderWithEverything`: + Database + Server URL
|
||||||
|
- Use `renderWithEverything` when components need database access
|
||||||
|
- Wrap async state updates in `act()` when testing React components
|
||||||
|
|
||||||
|
#### Remote Action Testing
|
||||||
|
- Mock `NetworkManager.getClient` in `beforeAll` to return mock client
|
||||||
|
- Mock client should implement all methods used in tests
|
||||||
|
- Test pattern: Setup DB state → Call action → Assert no error and data returned
|
||||||
|
- Pattern from `app/actions/remote/channel.test.ts`
|
||||||
|
|
||||||
|
#### DeviceEventEmitter Testing
|
||||||
|
- Use `DeviceEventEmitter.addListener()` to spy on events
|
||||||
|
- Remove listener in cleanup: `listener.remove()`
|
||||||
|
- Pattern: Store listener callback in jest.fn(), assert it was called with expected data
|
||||||
|
|
||||||
|
### Test Utilities
|
||||||
|
**TestHelper** singleton at `test/test_helper.ts`:
|
||||||
|
- `generateId()`: Deterministic IDs for tests
|
||||||
|
- `setupServerDatabase()`: Complete DB setup with basic entities
|
||||||
|
- `fakeUser()`, `fakeChannel()`, etc.: Entity generators with sensible defaults
|
||||||
|
- `fakeUserModel()`, `fakeChannelModel()`: WatermelonDB model creators
|
||||||
|
|
||||||
|
### Writing Quality Tests
|
||||||
|
|
||||||
|
**Test names:**
|
||||||
|
- Use `it('should...')` format, not `test('happy path')`
|
||||||
|
- Check array lengths in addition to individual items to ensure no extra elements
|
||||||
|
- Test expectations must match actual implementation behavior - if code deletes state immediately, test for `undefined`, not for modified state values
|
||||||
|
|
||||||
|
**Mocking:**
|
||||||
|
- Use `jest.mocked()` for full implementations
|
||||||
|
- Use `(thing as jest.Mock)` type assertion when mocking partial implementations that don't satisfy the full interface (e.g., `NetworkManager.getClient` returning minimal mock client)
|
||||||
|
- Avoid `any` types in tests - use `typeof import('./module').default` for proper typing of dynamically imported modules
|
||||||
|
|
||||||
|
**Don't test:**
|
||||||
|
- JavaScript operators (`===`, `JSON.stringify()`) or library behavior (React, RxJS)
|
||||||
|
- "Mocks calling mocks" - thin wrapper functions that just pass data through
|
||||||
|
- Multiple input variations when behavior is identical (testing 0, 1, 5 items when function doesn't special-case counts)
|
||||||
|
- Duplicate assertions - if test A verifies X, don't add test B that only verifies X
|
||||||
|
- AI-generated tests are heavily scrutinized and often rejected if they don't demonstrate real intended behavior
|
||||||
|
|
||||||
|
**Do test:**
|
||||||
|
- Business logic, error handling, integration points, side effects
|
||||||
|
- One example per code path is sufficient
|
||||||
|
|
||||||
|
## Development Practices
|
||||||
|
|
||||||
|
### TypeScript
|
||||||
|
- Trust well-formed data - don't add defensive null checks if types guarantee existence
|
||||||
|
- Use `??` (nullish coalescing) instead of `||` for fallbacks when appropriate
|
||||||
|
- Avoid non-null assertions (`!`) - use optional chaining: `metadataRes.metadata?.followers?.length ?? 0`
|
||||||
|
- Type function parameters using `ComponentProps<typeof Component>` instead of `as const` workarounds
|
||||||
|
- JSDoc comments must match actual parameter names and types
|
||||||
|
- **Prefer const objects over enums** for better tree-shaking: `export const Status = { Pending: 0, Done: 1 } as const;` with companion type `export type Status = typeof Status[keyof typeof Status];`
|
||||||
|
|
||||||
|
### React Hooks
|
||||||
|
- **Always explain why dependencies are omitted** from exhaustive-deps with specific comments
|
||||||
|
- Don't over-optimize with `useMemo` - only use for expensive calculations or objects passed as props to children
|
||||||
|
- Use `useCallback` for render functions passed to components, not for functions called within render
|
||||||
|
- Move module-level constants outside components
|
||||||
|
- Replace state variables with derived values when possible
|
||||||
|
- Stable references (refs, dispatch functions) don't need to be in dependency arrays but must have eslint-disable comments
|
||||||
|
- React-Native-Reanimated shared values (`.value`) don't cause re-renders and don't need to be dependencies
|
||||||
|
- **Critical**: When modifying existing `useEffect` hooks, you MUST include ALL dependencies used inside the effect, even if the original code had an incomplete dependency array - ESLint's `react-hooks/exhaustive-deps` rule is enforced strictly
|
||||||
|
- **Memoize callbacks for list items**: When rendering lists with `.map()`, avoid inline arrow functions like `onPress={() => handler(item.id)}`. Instead, have the child component accept the ID and call the callback internally, allowing the parent to pass a single memoized callback reference.
|
||||||
|
|
||||||
|
### React Native & UI
|
||||||
|
- Don't create hooks inside render functions - extract as local components
|
||||||
|
- Prefer `Button` components over `TouchableOpacity` when appropriate
|
||||||
|
- Non-memoized inline styles add render stress - define in stylesheet instead
|
||||||
|
- **`StyleSheet.create` is unnecessary** when using `makeStyleSheetFromTheme` - just return the plain object
|
||||||
|
- **Place `getStyleSheet` at file top** (after imports, before interfaces/components) not at the bottom
|
||||||
|
- Use `Platform.select()` for platform-specific values instead of ternaries
|
||||||
|
- StyleProps support nested lists - no need for custom `concatStyles()`
|
||||||
|
- Use `withTiming()` consistently for both states in animations
|
||||||
|
- Test components with long strings to ensure proper text handling
|
||||||
|
- Use constants from `PREFERENCES.THEMES` instead of hardcoded colors
|
||||||
|
- **Use `react-native-reanimated`** instead of React Native's `Animated` API for all animations (better performance, runs on UI thread)
|
||||||
|
- **Use `usePreventDoubleTap` hook** for button press handlers to prevent accidental double submissions
|
||||||
|
- **Use `useServerUrl()` hook** instead of passing `serverUrl` as a prop - it's available via context
|
||||||
|
- **Use existing components**: Check for `<Loading>` instead of `<ActivityIndicator>`, `safeParseJSON()` instead of try/catch JSON.parse
|
||||||
|
- **Parent checks before mounting**: If a child component would return null for empty data, have the parent conditionally render instead (e.g., `{items.length > 0 && <ItemList items={items} />}`)
|
||||||
|
- **Use `@utils/url` utilities**: `tryOpenURL()` instead of `Linking.openURL()`, `getUrlDomain()` with `urlParse` instead of `new URL()`
|
||||||
|
|
||||||
|
### Code Quality & Linting
|
||||||
|
|
||||||
|
**Import Organization:**
|
||||||
|
- Consolidate all imports from the same module into a single statement
|
||||||
|
- Use inline `type` keyword for type imports: `import {SomeValue, type SomeType} from '@module'`
|
||||||
|
- Example: `import {StreamingEvents, type StreamingState} from '@agents/types'`
|
||||||
|
|
||||||
|
**Unused Code:**
|
||||||
|
- Remove unused imports, parameters, interfaces, and variables completely
|
||||||
|
- ESLint enforces strict no-unused-vars - code with unused declarations will fail CI
|
||||||
|
- When a parameter is truly unused, remove it from the function signature
|
||||||
|
|
||||||
|
**Pre-commit:**
|
||||||
|
- Run `npm run fix` to auto-fix linting issues before committing
|
||||||
|
- Pre-commit hook runs ESLint + TypeScript checking automatically
|
||||||
|
|
||||||
|
### Error Handling & Logging
|
||||||
|
- Use `logError()` instead of `console.error()` or `console.log()`
|
||||||
|
- Use `logDebug()` for debug-level information
|
||||||
|
- Don't ignore potential errors silently - handle them or add intentional comments
|
||||||
|
- Don't log sensitive information
|
||||||
|
- **Add function/class prefix to logs**: e.g., `logError('[ClassName.methodName]', error)` to make debugging easier
|
||||||
|
|
||||||
|
### State Management
|
||||||
|
- Always handle errors from database operations
|
||||||
|
- Consider race conditions in async functions within effects
|
||||||
|
- Local state initialized with prop values won't update when props change - sync with `useEffect` if needed
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- Create parsers/expensive objects only once, not on every render (use refs or useMemo)
|
||||||
|
- Memoize AST output to avoid regenerating on every render
|
||||||
|
- Use named constants instead of magic numbers and check if one already exists.
|
||||||
|
|
||||||
|
### Localization (i18n)
|
||||||
|
- **CRITICAL**: Only update `en.json` - never modify other language files or Weblate gets corrupted
|
||||||
|
- Default messages in code must match JSON translations exactly, including newlines
|
||||||
|
- Translation IDs should be descriptive enough for translators to understand context
|
||||||
|
- Translate user-facing strings, not debug/error messages
|
||||||
|
|
||||||
|
### Markdown Component Usage
|
||||||
|
**Required props:**
|
||||||
|
- `baseTextStyle`, `value`, `theme`, `location`
|
||||||
|
- Use `Screens.CHANNEL` or appropriate constant from `@constants` for the `location` prop
|
||||||
|
- `textStyles`, `blockStyles`, `enableLatex`, and similar props are auto-generated via HOC
|
||||||
|
|
||||||
|
**Important Limitations:**
|
||||||
|
- **Cannot embed custom React components inline**: The Markdown component renders CommonMark to native components. You cannot insert custom React components (like badges, icons) inline within the markdown text flow
|
||||||
|
- **Link handling**: Links in markdown go through `openLink()` → deep link system → `tryOpenURL()`. Custom URL schemes (e.g., `citation:`, `action:`) will show error alerts unless you implement a full deep link handler in `app/utils/deep_link/`
|
||||||
|
- **Post-processing not possible**: Unlike web, you cannot post-process the rendered markdown JSX tree to replace markers with React components
|
||||||
|
|
||||||
|
### Adding New Post Types
|
||||||
|
When adding custom post types (e.g., for new products):
|
||||||
|
1. Add the type string to `PostType` union in `types/api/posts.d.ts`
|
||||||
|
2. Define constants in your product's constants file (e.g., `app/products/agents/constants.ts`)
|
||||||
|
3. Use the globally-available `Post` type (defined in `types/api/posts.d.ts`) - no import needed
|
||||||
|
|
||||||
|
### Platform-Specific
|
||||||
|
- iOS: Opt out of iOS 18+ features (liquid glass) until UI is properly addressed
|
||||||
|
- Android: Test on multiple API levels (34, 35, 36) to verify behavior with edge-to-edge changes
|
||||||
|
- Use `Set` instead of `Array` for exception lists (faster, ensures uniqueness)
|
||||||
|
|
||||||
|
### testID Convention
|
||||||
|
Components use hierarchical testIDs: `component.subcomponent.element`
|
||||||
|
- Example: `channel_list.category.CATEGORY.channel_item.ID.display_name`
|
||||||
|
- Example: `channel.post_draft.send_action.send.button`
|
||||||
|
|
||||||
|
## Common Mistakes to Avoid
|
||||||
|
|
||||||
|
### JavaScript Compatibility
|
||||||
|
- **Don't use `Object.hasOwn()`** - React Native doesn't support ES2022+ features
|
||||||
|
- ❌ `Object.hasOwn(obj, 'key')`
|
||||||
|
- ✅ `'key' in obj`
|
||||||
|
|
||||||
|
### Anti-Patterns
|
||||||
|
- Conditionals in tests for TypeScript satisfaction
|
||||||
|
- Creating new arrays/objects on every render when passed as props
|
||||||
|
- Over-engineering - follow YAGNI principle
|
||||||
|
- Impossible states with boolean + optional parameters that depend on each other
|
||||||
|
- Redundant checks - if `array.length >= 3`, indices 0,1,2 are guaranteed to exist
|
||||||
|
- Verbose error messages that reveal internal system details
|
||||||
|
- Mass assignment risks - whitelist fields instead of sending `Partial<Model>` objects
|
||||||
|
- Duplicate conditional branches that return identical results - consolidate into a single condition (e.g., `if (A && B)` and `if (A && !B)` both returning same thing should be just `if (A)`)
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- Question whether showing secrets with "eye" toggle is a security concern
|
||||||
|
- Don't commit files that likely contain secrets (.env, credentials.json)
|
||||||
|
- Consider pre-populating sensitive fields with `*********` instead of actual values
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
- **Patches**: Applied via `patch-package` on postinstall (`patches/` directory)
|
||||||
|
- **Self-compiled apps**: Require your own Mattermost Push Notification Service
|
||||||
|
- **Self-signed certificates**: Not supported
|
||||||
|
|
||||||
|
## Development Notes
|
||||||
|
|
||||||
|
### Hot Reload & Build Times
|
||||||
|
- **Hot reload**: ~3 seconds for JS/TS changes; does NOT work for native code changes
|
||||||
|
- **Native rebuilds**: 10-30 minutes (avoid unless necessary)
|
||||||
|
- **Pre-commit hook**: Runs ESLint + incremental TypeScript checking
|
||||||
|
|
||||||
|
### Known Issues
|
||||||
|
- Many components require `theme` prop - check for `useTheme()` hook in parent component
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {handleAgentPostUpdate} from '@agents/actions/websocket';
|
||||||
|
|
||||||
import * as bookmark from '@actions/local/channel_bookmark';
|
import * as bookmark from '@actions/local/channel_bookmark';
|
||||||
import * as scheduledPost from '@actions/websocket/scheduled_post';
|
import * as scheduledPost from '@actions/websocket/scheduled_post';
|
||||||
import * as calls from '@calls/connection/websocket_event_handlers';
|
import * as calls from '@calls/connection/websocket_event_handlers';
|
||||||
|
|
@ -301,6 +303,11 @@ export async function handleWebSocketEvent(serverUrl: string, msg: WebSocketMess
|
||||||
case WebsocketEvents.CUSTOM_PROFILE_ATTRIBUTES_FIELD_DELETED:
|
case WebsocketEvents.CUSTOM_PROFILE_ATTRIBUTES_FIELD_DELETED:
|
||||||
handleCustomProfileAttributesFieldDeletedEvent(serverUrl, msg);
|
handleCustomProfileAttributesFieldDeletedEvent(serverUrl, msg);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
// Agents
|
||||||
|
case WebsocketEvents.AGENTS_POST_UPDATE:
|
||||||
|
handleAgentPostUpdate(msg);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
handlePlaybookEvents(serverUrl, msg);
|
handlePlaybookEvents(serverUrl, msg);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import streamingStore from '@agents/store/streaming_store';
|
||||||
|
import {isAgentPost} from '@agents/utils';
|
||||||
import {DeviceEventEmitter} from 'react-native';
|
import {DeviceEventEmitter} from 'react-native';
|
||||||
|
|
||||||
import {storeMyChannelsForTeam, markChannelAsUnread, markChannelAsViewed, updateLastPostAt} from '@actions/local/channel';
|
import {storeMyChannelsForTeam, markChannelAsUnread, markChannelAsViewed, updateLastPostAt} from '@actions/local/channel';
|
||||||
|
|
@ -267,7 +269,11 @@ export async function handlePostEdited(serverUrl: string, msg: WebSocketMessage)
|
||||||
});
|
});
|
||||||
models.push(...postModels);
|
models.push(...postModels);
|
||||||
|
|
||||||
operator.batchRecords(models, 'handlePostEdited');
|
await operator.batchRecords(models, 'handlePostEdited');
|
||||||
|
|
||||||
|
if (isAgentPost(post)) {
|
||||||
|
streamingStore.removePost(post.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handlePostDeleted(serverUrl: string, msg: WebSocketMessage) {
|
export async function handlePostDeleted(serverUrl: string, msg: WebSocketMessage) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import ClientAgents, {type ClientAgentsMix} from '@agents/client/rest';
|
||||||
|
|
||||||
import ClientCalls, {type ClientCallsMix} from '@calls/client/rest';
|
import ClientCalls, {type ClientCallsMix} from '@calls/client/rest';
|
||||||
import ClientPlugins, {type ClientPluginsMix} from '@client/rest/plugins';
|
import ClientPlugins, {type ClientPluginsMix} from '@client/rest/plugins';
|
||||||
import ClientPlaybooks, {type ClientPlaybooksMix} from '@playbooks/client/rest';
|
import ClientPlaybooks, {type ClientPlaybooksMix} from '@playbooks/client/rest';
|
||||||
|
|
@ -30,6 +32,7 @@ import ClientUsers, {type ClientUsersMix} from './users';
|
||||||
import type {APIClientInterface} from '@mattermost/react-native-network-client';
|
import type {APIClientInterface} from '@mattermost/react-native-network-client';
|
||||||
|
|
||||||
interface Client extends ClientBase,
|
interface Client extends ClientBase,
|
||||||
|
ClientAgentsMix,
|
||||||
ClientAppsMix,
|
ClientAppsMix,
|
||||||
ClientCategoriesMix,
|
ClientCategoriesMix,
|
||||||
ClientChannelsMix,
|
ClientChannelsMix,
|
||||||
|
|
@ -57,6 +60,7 @@ interface Client extends ClientBase,
|
||||||
}
|
}
|
||||||
|
|
||||||
class Client extends mix(ClientBase).with(
|
class Client extends mix(ClientBase).with(
|
||||||
|
ClientAgents,
|
||||||
ClientApps,
|
ClientApps,
|
||||||
ClientCategories,
|
ClientCategories,
|
||||||
ClientChannels,
|
ClientChannels,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import AgentPost from '@agents/components/agent_post';
|
||||||
|
import {isAgentPost} from '@agents/utils';
|
||||||
import React, {type ReactNode, useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
import React, {type ReactNode, useCallback, useEffect, useMemo, useRef, useState} from 'react';
|
||||||
import {useIntl} from 'react-intl';
|
import {useIntl} from 'react-intl';
|
||||||
import {Keyboard, Platform, type StyleProp, View, type ViewStyle, TouchableHighlight} from 'react-native';
|
import {Keyboard, Platform, type StyleProp, View, type ViewStyle, TouchableHighlight} from 'react-native';
|
||||||
|
|
@ -157,6 +159,7 @@ const Post = ({
|
||||||
const isFailed = isPostFailed(post);
|
const isFailed = isPostFailed(post);
|
||||||
const isSystemPost = isSystemMessage(post);
|
const isSystemPost = isSystemMessage(post);
|
||||||
const isCallsPost = isCallsCustomMessage(post);
|
const isCallsPost = isCallsCustomMessage(post);
|
||||||
|
const isAgentPostType = isAgentPost(post);
|
||||||
const hasBeenDeleted = (post.deleteAt !== 0);
|
const hasBeenDeleted = (post.deleteAt !== 0);
|
||||||
const isWebHook = isFromWebhook(post);
|
const isWebHook = isFromWebhook(post);
|
||||||
const hasSameRoot = useMemo(() => {
|
const hasSameRoot = useMemo(() => {
|
||||||
|
|
@ -251,6 +254,8 @@ const Post = ({
|
||||||
clearTimeout(t);
|
clearTimeout(t);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- Timer only needs to reset when post.id changes, not on other prop updates
|
||||||
}, [post.id]);
|
}, [post.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -264,6 +269,8 @@ const Post = ({
|
||||||
|
|
||||||
PerformanceMetricsManager.finishLoad(location === 'Thread' ? 'THREAD' : 'CHANNEL', serverUrl);
|
PerformanceMetricsManager.finishLoad(location === 'Thread' ? 'THREAD' : 'CHANNEL', serverUrl);
|
||||||
PerformanceMetricsManager.endMetric('mobile_channel_switch', serverUrl);
|
PerformanceMetricsManager.endMetric('mobile_channel_switch', serverUrl);
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- Performance metrics should only run once on mount
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const highlightSaved = isSaved && !skipSavedHeader;
|
const highlightSaved = isSaved && !skipSavedHeader;
|
||||||
|
|
@ -356,6 +363,14 @@ const Post = ({
|
||||||
joiningChannelId={null}
|
joiningChannelId={null}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
} else if (isAgentPostType && !hasBeenDeleted) {
|
||||||
|
body = (
|
||||||
|
<AgentPost
|
||||||
|
post={post}
|
||||||
|
currentUserId={currentUser?.id}
|
||||||
|
location={location}
|
||||||
|
/>
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
body = (
|
body = (
|
||||||
<Body
|
<Body
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,8 @@ export const PostTypes = {
|
||||||
SYSTEM_AUTO_RESPONDER: 'system_auto_responder',
|
SYSTEM_AUTO_RESPONDER: 'system_auto_responder',
|
||||||
CUSTOM_CALLS: 'custom_calls',
|
CUSTOM_CALLS: 'custom_calls',
|
||||||
CUSTOM_CALLS_RECORDING: 'custom_calls_recording',
|
CUSTOM_CALLS_RECORDING: 'custom_calls_recording',
|
||||||
|
CUSTOM_LLMBOT: 'custom_llmbot',
|
||||||
|
CUSTOM_LLM_POSTBACK: 'custom_llm_postback',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const PostPriorityColors = {
|
export const PostPriorityColors = {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,9 @@ import keyMirror from '@utils/key_mirror';
|
||||||
|
|
||||||
export const SNACK_BAR_TYPE = keyMirror({
|
export const SNACK_BAR_TYPE = keyMirror({
|
||||||
ADD_CHANNEL_MEMBERS: null,
|
ADD_CHANNEL_MEMBERS: null,
|
||||||
|
AGENT_STOP_ERROR: null,
|
||||||
|
AGENT_REGENERATE_ERROR: null,
|
||||||
|
AGENT_TOOL_APPROVAL_ERROR: null,
|
||||||
CODE_COPIED: null,
|
CODE_COPIED: null,
|
||||||
FAVORITE_CHANNEL: null,
|
FAVORITE_CHANNEL: null,
|
||||||
FOLLOW_THREAD: null,
|
FOLLOW_THREAD: null,
|
||||||
|
|
@ -46,6 +49,18 @@ const messages = defineMessages({
|
||||||
id: 'snack.bar.channel.members.added',
|
id: 'snack.bar.channel.members.added',
|
||||||
defaultMessage: '{numMembers, number} {numMembers, plural, one {member} other {members}} added',
|
defaultMessage: '{numMembers, number} {numMembers, plural, one {member} other {members}} added',
|
||||||
},
|
},
|
||||||
|
AGENT_STOP_ERROR: {
|
||||||
|
id: 'snack.bar.agent.stop.error',
|
||||||
|
defaultMessage: 'Failed to stop generation',
|
||||||
|
},
|
||||||
|
AGENT_REGENERATE_ERROR: {
|
||||||
|
id: 'snack.bar.agent.regenerate.error',
|
||||||
|
defaultMessage: 'Failed to regenerate response',
|
||||||
|
},
|
||||||
|
AGENT_TOOL_APPROVAL_ERROR: {
|
||||||
|
id: 'snack.bar.agent.tool.approval.error',
|
||||||
|
defaultMessage: 'Failed to submit tool approval',
|
||||||
|
},
|
||||||
CODE_COPIED: {
|
CODE_COPIED: {
|
||||||
id: 'snack.bar.code.copied',
|
id: 'snack.bar.code.copied',
|
||||||
defaultMessage: 'Code copied to clipboard',
|
defaultMessage: 'Code copied to clipboard',
|
||||||
|
|
@ -110,6 +125,24 @@ export const SNACK_BAR_CONFIG: Record<string, SnackBarConfig> = {
|
||||||
iconName: 'check',
|
iconName: 'check',
|
||||||
canUndo: false,
|
canUndo: false,
|
||||||
},
|
},
|
||||||
|
AGENT_STOP_ERROR: {
|
||||||
|
message: messages.AGENT_STOP_ERROR,
|
||||||
|
iconName: 'alert-outline',
|
||||||
|
canUndo: false,
|
||||||
|
type: MESSAGE_TYPE.ERROR,
|
||||||
|
},
|
||||||
|
AGENT_REGENERATE_ERROR: {
|
||||||
|
message: messages.AGENT_REGENERATE_ERROR,
|
||||||
|
iconName: 'alert-outline',
|
||||||
|
canUndo: false,
|
||||||
|
type: MESSAGE_TYPE.ERROR,
|
||||||
|
},
|
||||||
|
AGENT_TOOL_APPROVAL_ERROR: {
|
||||||
|
message: messages.AGENT_TOOL_APPROVAL_ERROR,
|
||||||
|
iconName: 'alert-outline',
|
||||||
|
canUndo: false,
|
||||||
|
type: MESSAGE_TYPE.ERROR,
|
||||||
|
},
|
||||||
CODE_COPIED: {
|
CODE_COPIED: {
|
||||||
message: messages.CODE_COPIED,
|
message: messages.CODE_COPIED,
|
||||||
iconName: 'content-copy',
|
iconName: 'content-copy',
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,9 @@ const WebsocketEvents = {
|
||||||
CUSTOM_PROFILE_ATTRIBUTES_FIELD_UPDATED: 'custom_profile_attributes_field_updated',
|
CUSTOM_PROFILE_ATTRIBUTES_FIELD_UPDATED: 'custom_profile_attributes_field_updated',
|
||||||
CUSTOM_PROFILE_ATTRIBUTES_FIELD_CREATED: 'custom_profile_attributes_field_created',
|
CUSTOM_PROFILE_ATTRIBUTES_FIELD_CREATED: 'custom_profile_attributes_field_created',
|
||||||
CUSTOM_PROFILE_ATTRIBUTES_FIELD_DELETED: 'custom_profile_attributes_field_deleted',
|
CUSTOM_PROFILE_ATTRIBUTES_FIELD_DELETED: 'custom_profile_attributes_field_deleted',
|
||||||
|
|
||||||
|
// Agents
|
||||||
|
AGENTS_POST_UPDATE: 'custom_mattermost-ai_postupdate',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default WebsocketEvents;
|
export default WebsocketEvents;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import NetworkManager from '@managers/network_manager';
|
||||||
|
import {getFullErrorMessage} from '@utils/errors';
|
||||||
|
import {logError} from '@utils/log';
|
||||||
|
|
||||||
|
import {
|
||||||
|
stopGeneration,
|
||||||
|
regenerateResponse,
|
||||||
|
} from './generation_controls';
|
||||||
|
|
||||||
|
jest.mock('@managers/network_manager');
|
||||||
|
jest.mock('@utils/errors');
|
||||||
|
jest.mock('@utils/log');
|
||||||
|
|
||||||
|
const serverUrl = 'https://test.mattermost.com';
|
||||||
|
const postId = 'test-post-id';
|
||||||
|
|
||||||
|
const mockClient = {
|
||||||
|
stopGeneration: jest.fn(),
|
||||||
|
regenerateResponse: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
jest.mocked(NetworkManager.getClient).mockReturnValue(mockClient as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stopGeneration', () => {
|
||||||
|
it('should call client.stopGeneration and return empty object on success', async () => {
|
||||||
|
mockClient.stopGeneration.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const result = await stopGeneration(serverUrl, postId);
|
||||||
|
|
||||||
|
expect(NetworkManager.getClient).toHaveBeenCalledWith(serverUrl);
|
||||||
|
expect(mockClient.stopGeneration).toHaveBeenCalledWith(postId);
|
||||||
|
expect(result).toEqual({});
|
||||||
|
expect(result.error).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error object and log error on failure', async () => {
|
||||||
|
const error = new Error('Network error');
|
||||||
|
const errorMessage = 'Network error occurred';
|
||||||
|
mockClient.stopGeneration.mockRejectedValue(error);
|
||||||
|
jest.mocked(getFullErrorMessage).mockReturnValue(errorMessage);
|
||||||
|
|
||||||
|
const result = await stopGeneration(serverUrl, postId);
|
||||||
|
|
||||||
|
expect(logError).toHaveBeenCalledWith('[stopGeneration]', error);
|
||||||
|
expect(getFullErrorMessage).toHaveBeenCalledWith(error);
|
||||||
|
expect(result).toEqual({error: errorMessage});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('regenerateResponse', () => {
|
||||||
|
it('should call client.regenerateResponse and return empty object on success', async () => {
|
||||||
|
mockClient.regenerateResponse.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const result = await regenerateResponse(serverUrl, postId);
|
||||||
|
|
||||||
|
expect(NetworkManager.getClient).toHaveBeenCalledWith(serverUrl);
|
||||||
|
expect(mockClient.regenerateResponse).toHaveBeenCalledWith(postId);
|
||||||
|
expect(result).toEqual({});
|
||||||
|
expect(result.error).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error object and log error on failure', async () => {
|
||||||
|
const error = new Error('Network error');
|
||||||
|
const errorMessage = 'Network error occurred';
|
||||||
|
mockClient.regenerateResponse.mockRejectedValue(error);
|
||||||
|
jest.mocked(getFullErrorMessage).mockReturnValue(errorMessage);
|
||||||
|
|
||||||
|
const result = await regenerateResponse(serverUrl, postId);
|
||||||
|
|
||||||
|
expect(logError).toHaveBeenCalledWith('[regenerateResponse]', error);
|
||||||
|
expect(getFullErrorMessage).toHaveBeenCalledWith(error);
|
||||||
|
expect(result).toEqual({error: errorMessage});
|
||||||
|
});
|
||||||
|
});
|
||||||
46
app/products/agents/actions/remote/generation_controls.ts
Normal file
46
app/products/agents/actions/remote/generation_controls.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import NetworkManager from '@managers/network_manager';
|
||||||
|
import {getFullErrorMessage} from '@utils/errors';
|
||||||
|
import {logError} from '@utils/log';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the current generation for a post
|
||||||
|
* @param serverUrl The server URL
|
||||||
|
* @param postId The post ID to stop generation for
|
||||||
|
* @returns {error} on failure
|
||||||
|
*/
|
||||||
|
export async function stopGeneration(
|
||||||
|
serverUrl: string,
|
||||||
|
postId: string,
|
||||||
|
): Promise<{error?: unknown}> {
|
||||||
|
try {
|
||||||
|
const client = NetworkManager.getClient(serverUrl);
|
||||||
|
await client.stopGeneration(postId);
|
||||||
|
return {};
|
||||||
|
} catch (error) {
|
||||||
|
logError('[stopGeneration]', error);
|
||||||
|
return {error: getFullErrorMessage(error)};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regenerate a response for a post
|
||||||
|
* @param serverUrl The server URL
|
||||||
|
* @param postId The post ID to regenerate response for
|
||||||
|
* @returns {error} on failure
|
||||||
|
*/
|
||||||
|
export async function regenerateResponse(
|
||||||
|
serverUrl: string,
|
||||||
|
postId: string,
|
||||||
|
): Promise<{error?: unknown}> {
|
||||||
|
try {
|
||||||
|
const client = NetworkManager.getClient(serverUrl);
|
||||||
|
await client.regenerateResponse(postId);
|
||||||
|
return {};
|
||||||
|
} catch (error) {
|
||||||
|
logError('[regenerateResponse]', error);
|
||||||
|
return {error: getFullErrorMessage(error)};
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/products/agents/actions/remote/tool_approval.test.ts
Normal file
54
app/products/agents/actions/remote/tool_approval.test.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import NetworkManager from '@managers/network_manager';
|
||||||
|
import {getFullErrorMessage} from '@utils/errors';
|
||||||
|
import {logError} from '@utils/log';
|
||||||
|
|
||||||
|
import {submitToolApproval} from './tool_approval';
|
||||||
|
|
||||||
|
jest.mock('@managers/network_manager');
|
||||||
|
jest.mock('@utils/errors');
|
||||||
|
jest.mock('@utils/log');
|
||||||
|
|
||||||
|
const serverUrl = 'https://test.mattermost.com';
|
||||||
|
const postId = 'post123';
|
||||||
|
const acceptedToolIds = ['tool1', 'tool2', 'tool3'];
|
||||||
|
|
||||||
|
const mockClient = {
|
||||||
|
submitToolApproval: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
jest.mocked(NetworkManager.getClient).mockReturnValue(mockClient as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('submitToolApproval', () => {
|
||||||
|
it('should call client.submitToolApproval and return empty object on success', async () => {
|
||||||
|
mockClient.submitToolApproval.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
const result = await submitToolApproval(serverUrl, postId, acceptedToolIds);
|
||||||
|
|
||||||
|
expect(NetworkManager.getClient).toHaveBeenCalledWith(serverUrl);
|
||||||
|
expect(mockClient.submitToolApproval).toHaveBeenCalledWith(postId, acceptedToolIds);
|
||||||
|
expect(result).toEqual({});
|
||||||
|
expect(result.error).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return error object and log error on failure', async () => {
|
||||||
|
const error = new Error('Network error');
|
||||||
|
const errorMessage = 'Network error occurred';
|
||||||
|
mockClient.submitToolApproval.mockRejectedValue(error);
|
||||||
|
jest.mocked(getFullErrorMessage).mockReturnValue(errorMessage);
|
||||||
|
|
||||||
|
const result = await submitToolApproval(serverUrl, postId, acceptedToolIds);
|
||||||
|
|
||||||
|
expect(logError).toHaveBeenCalledWith('[submitToolApproval]', error);
|
||||||
|
expect(getFullErrorMessage).toHaveBeenCalledWith(error);
|
||||||
|
expect(result).toEqual({error: errorMessage});
|
||||||
|
});
|
||||||
|
});
|
||||||
28
app/products/agents/actions/remote/tool_approval.ts
Normal file
28
app/products/agents/actions/remote/tool_approval.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import NetworkManager from '@managers/network_manager';
|
||||||
|
import {getFullErrorMessage} from '@utils/errors';
|
||||||
|
import {logError} from '@utils/log';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit tool approval decisions to the server
|
||||||
|
* @param serverUrl The server URL
|
||||||
|
* @param postId The post ID containing the tool calls
|
||||||
|
* @param acceptedToolIds Array of tool IDs that were approved
|
||||||
|
* @returns {error} on failure
|
||||||
|
*/
|
||||||
|
export async function submitToolApproval(
|
||||||
|
serverUrl: string,
|
||||||
|
postId: string,
|
||||||
|
acceptedToolIds: string[],
|
||||||
|
): Promise<{error?: unknown}> {
|
||||||
|
try {
|
||||||
|
const client = NetworkManager.getClient(serverUrl);
|
||||||
|
await client.submitToolApproval(postId, acceptedToolIds);
|
||||||
|
return {};
|
||||||
|
} catch (error) {
|
||||||
|
logError('[submitToolApproval]', error);
|
||||||
|
return {error: getFullErrorMessage(error)};
|
||||||
|
}
|
||||||
|
}
|
||||||
83
app/products/agents/actions/websocket/index.test.ts
Normal file
83
app/products/agents/actions/websocket/index.test.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import streamingStore from '@agents/store/streaming_store';
|
||||||
|
|
||||||
|
import {handleAgentPostUpdate} from './index';
|
||||||
|
|
||||||
|
import type {PostUpdateWebsocketMessage} from '@agents/types';
|
||||||
|
|
||||||
|
// Mock the streaming store
|
||||||
|
jest.mock('@agents/store/streaming_store', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: {
|
||||||
|
handleWebSocketMessage: jest.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('handleAgentPostUpdate', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call streamingStore.handleWebSocketMessage with message data', () => {
|
||||||
|
const messageData: PostUpdateWebsocketMessage = {
|
||||||
|
post_id: 'post123',
|
||||||
|
next: 'Hello world',
|
||||||
|
control: 'start',
|
||||||
|
};
|
||||||
|
|
||||||
|
const msg: WebSocketMessage<PostUpdateWebsocketMessage> = {
|
||||||
|
event: 'custom_mattermost-ai_postupdate',
|
||||||
|
data: messageData,
|
||||||
|
broadcast: {
|
||||||
|
omit_users: {},
|
||||||
|
user_id: 'user123',
|
||||||
|
channel_id: 'channel123',
|
||||||
|
team_id: 'team123',
|
||||||
|
},
|
||||||
|
seq: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
handleAgentPostUpdate(msg);
|
||||||
|
|
||||||
|
expect(streamingStore.handleWebSocketMessage).toHaveBeenCalledTimes(1);
|
||||||
|
expect(streamingStore.handleWebSocketMessage).toHaveBeenCalledWith(messageData);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return early when data is undefined', () => {
|
||||||
|
const msg = {
|
||||||
|
event: 'custom_mattermost-ai_postupdate',
|
||||||
|
data: undefined,
|
||||||
|
broadcast: {
|
||||||
|
omit_users: {},
|
||||||
|
user_id: 'user123',
|
||||||
|
channel_id: 'channel123',
|
||||||
|
team_id: 'team123',
|
||||||
|
},
|
||||||
|
seq: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
handleAgentPostUpdate(msg as unknown as WebSocketMessage<PostUpdateWebsocketMessage>);
|
||||||
|
|
||||||
|
expect(streamingStore.handleWebSocketMessage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return early when data is null', () => {
|
||||||
|
const msg = {
|
||||||
|
event: 'custom_mattermost-ai_postupdate',
|
||||||
|
data: null,
|
||||||
|
broadcast: {
|
||||||
|
omit_users: {},
|
||||||
|
user_id: 'user123',
|
||||||
|
channel_id: 'channel123',
|
||||||
|
team_id: 'team123',
|
||||||
|
},
|
||||||
|
seq: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
handleAgentPostUpdate(msg as unknown as WebSocketMessage<PostUpdateWebsocketMessage>);
|
||||||
|
|
||||||
|
expect(streamingStore.handleWebSocketMessage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
19
app/products/agents/actions/websocket/index.ts
Normal file
19
app/products/agents/actions/websocket/index.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import streamingStore from '@agents/store/streaming_store';
|
||||||
|
|
||||||
|
import type {PostUpdateWebsocketMessage} from '@agents/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle agent post update WebSocket events
|
||||||
|
* Called when the server sends streaming updates for agent responses
|
||||||
|
*/
|
||||||
|
export function handleAgentPostUpdate(msg: WebSocketMessage<PostUpdateWebsocketMessage>): void {
|
||||||
|
if (!msg.data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delegate to the streaming store
|
||||||
|
streamingStore.handleWebSocketMessage(msg.data);
|
||||||
|
}
|
||||||
41
app/products/agents/client/rest.ts
Normal file
41
app/products/agents/client/rest.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
export interface ClientAgentsMix {
|
||||||
|
getAgentsRoute: () => string;
|
||||||
|
stopGeneration: (postId: string) => Promise<void>;
|
||||||
|
regenerateResponse: (postId: string) => Promise<void>;
|
||||||
|
submitToolApproval: (postId: string, acceptedToolIds: string[]) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ClientAgents = (superclass: any) => class extends superclass {
|
||||||
|
getAgentsRoute = () => {
|
||||||
|
return '/plugins/mattermost-ai';
|
||||||
|
};
|
||||||
|
|
||||||
|
stopGeneration = async (postId: string) => {
|
||||||
|
return this.doFetch(
|
||||||
|
`${this.getAgentsRoute()}/post/${postId}/stop`,
|
||||||
|
{method: 'post'},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
regenerateResponse = async (postId: string) => {
|
||||||
|
return this.doFetch(
|
||||||
|
`${this.getAgentsRoute()}/post/${postId}/regenerate`,
|
||||||
|
{method: 'post'},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
submitToolApproval = async (postId: string, acceptedToolIds: string[]) => {
|
||||||
|
return this.doFetch(
|
||||||
|
`${this.getAgentsRoute()}/post/${postId}/tool_call`,
|
||||||
|
{
|
||||||
|
method: 'post',
|
||||||
|
body: {accepted_tool_ids: acceptedToolIds},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ClientAgents;
|
||||||
203
app/products/agents/components/agent_post/index.tsx
Normal file
203
app/products/agents/components/agent_post/index.tsx
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {regenerateResponse, stopGeneration} from '@agents/actions/remote/generation_controls';
|
||||||
|
import {useStreamingState} from '@agents/store/streaming_store';
|
||||||
|
import {type Annotation, type ToolCall} from '@agents/types';
|
||||||
|
import {isPostRequester} from '@agents/utils';
|
||||||
|
import React, {useCallback, useMemo} from 'react';
|
||||||
|
import {View} from 'react-native';
|
||||||
|
|
||||||
|
import FormattedText from '@components/formatted_text';
|
||||||
|
import Markdown from '@components/markdown';
|
||||||
|
import {SNACK_BAR_TYPE} from '@constants/snack_bar';
|
||||||
|
import {useServerUrl} from '@context/server';
|
||||||
|
import {useTheme} from '@context/theme';
|
||||||
|
import {safeParseJSON} from '@utils/helpers';
|
||||||
|
import {showSnackBar} from '@utils/snack_bar';
|
||||||
|
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||||
|
|
||||||
|
import CitationsList from '../citations_list';
|
||||||
|
import ControlsBar from '../controls_bar';
|
||||||
|
import ReasoningDisplay from '../reasoning_display';
|
||||||
|
import ToolApprovalSet from '../tool_approval_set';
|
||||||
|
|
||||||
|
import StreamingIndicator from './streaming_indicator';
|
||||||
|
|
||||||
|
import type PostModel from '@typings/database/models/servers/post';
|
||||||
|
import type {AvailableScreens} from '@typings/screens/navigation';
|
||||||
|
|
||||||
|
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||||
|
return {
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
messageContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
},
|
||||||
|
messageText: {
|
||||||
|
color: theme.centerChannelColor,
|
||||||
|
fontSize: 15,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
precontentContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 8,
|
||||||
|
},
|
||||||
|
precontentText: {
|
||||||
|
color: changeOpacity(theme.centerChannelColor, 0.6),
|
||||||
|
fontSize: 14,
|
||||||
|
fontStyle: 'italic',
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
interface AgentPostProps {
|
||||||
|
post: PostModel;
|
||||||
|
currentUserId?: string;
|
||||||
|
location: AvailableScreens;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom post component for agent responses
|
||||||
|
* Handles streaming text updates and displays animated cursor during generation
|
||||||
|
*/
|
||||||
|
const AgentPost = ({post, currentUserId, location}: AgentPostProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const styles = getStyleSheet(theme);
|
||||||
|
const serverUrl = useServerUrl();
|
||||||
|
|
||||||
|
// Extract persisted reasoning from post props
|
||||||
|
const persistedReasoning = useMemo(() => {
|
||||||
|
const props = post.props as Record<string, unknown>;
|
||||||
|
return (props?.reasoning_summary as string) || '';
|
||||||
|
}, [post.props]);
|
||||||
|
|
||||||
|
// Extract persisted tool calls from post props
|
||||||
|
const persistedToolCalls = useMemo((): ToolCall[] => {
|
||||||
|
const props = post.props as Record<string, unknown>;
|
||||||
|
const toolCallsJson = props?.pending_tool_call as string;
|
||||||
|
if (toolCallsJson) {
|
||||||
|
const parsed = safeParseJSON(toolCallsJson);
|
||||||
|
return Array.isArray(parsed) ? parsed as ToolCall[] : [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}, [post.props]);
|
||||||
|
|
||||||
|
// Extract persisted annotations from post props
|
||||||
|
const persistedAnnotations = useMemo((): Annotation[] => {
|
||||||
|
const props = post.props as Record<string, unknown>;
|
||||||
|
const annotationsJson = props?.annotations as string;
|
||||||
|
if (annotationsJson) {
|
||||||
|
const parsed = safeParseJSON(annotationsJson);
|
||||||
|
return Array.isArray(parsed) ? parsed as Annotation[] : [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}, [post.props]);
|
||||||
|
|
||||||
|
// Subscribe to streaming state via observable
|
||||||
|
const streamingState = useStreamingState(post.id);
|
||||||
|
|
||||||
|
// Determine the message to display (use ?? not || to preserve empty string during streaming)
|
||||||
|
const displayMessage = streamingState?.message ?? post.message ?? '';
|
||||||
|
const isGenerating = streamingState?.generating ?? false;
|
||||||
|
const isPrecontent = streamingState?.precontent ?? false;
|
||||||
|
|
||||||
|
// Determine reasoning state - use streaming state if available, otherwise use persisted
|
||||||
|
const reasoningSummary = streamingState?.reasoning ?? persistedReasoning;
|
||||||
|
const isReasoningLoading = streamingState?.isReasoningLoading ?? false;
|
||||||
|
const showReasoning = streamingState?.showReasoning ?? (persistedReasoning !== '');
|
||||||
|
|
||||||
|
// Determine tool calls - use streaming state if available, otherwise use persisted
|
||||||
|
const toolCalls = streamingState?.toolCalls ?? persistedToolCalls;
|
||||||
|
|
||||||
|
// Determine annotations - use streaming state if available, otherwise use persisted
|
||||||
|
const annotations = streamingState?.annotations ?? persistedAnnotations;
|
||||||
|
|
||||||
|
// Check permissions
|
||||||
|
const isRequester = useMemo(() => {
|
||||||
|
return currentUserId ? isPostRequester(post, currentUserId) : false;
|
||||||
|
}, [post, currentUserId]);
|
||||||
|
|
||||||
|
// Determine if generation is in progress (generating or reasoning)
|
||||||
|
const isGenerationInProgress = isGenerating || isReasoningLoading;
|
||||||
|
|
||||||
|
// Show controls based on state and permissions
|
||||||
|
const showStopButton = isGenerationInProgress && isRequester;
|
||||||
|
const hasContent = displayMessage !== '' || reasoningSummary !== '';
|
||||||
|
const showRegenerateButton = !isGenerationInProgress && isRequester && hasContent;
|
||||||
|
|
||||||
|
// Handler for stop button
|
||||||
|
const handleStop = useCallback(async () => {
|
||||||
|
const {error} = await stopGeneration(serverUrl, post.id);
|
||||||
|
if (error) {
|
||||||
|
showSnackBar({barType: SNACK_BAR_TYPE.AGENT_STOP_ERROR});
|
||||||
|
}
|
||||||
|
}, [serverUrl, post.id]);
|
||||||
|
|
||||||
|
// Handler for regenerate button
|
||||||
|
const handleRegenerate = useCallback(async () => {
|
||||||
|
const {error} = await regenerateResponse(serverUrl, post.id);
|
||||||
|
if (error) {
|
||||||
|
showSnackBar({barType: SNACK_BAR_TYPE.AGENT_REGENERATE_ERROR});
|
||||||
|
}
|
||||||
|
}, [serverUrl, post.id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{showReasoning && (
|
||||||
|
<ReasoningDisplay
|
||||||
|
reasoningSummary={reasoningSummary}
|
||||||
|
isReasoningLoading={isReasoningLoading}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isPrecontent ? (
|
||||||
|
<View style={styles.precontentContainer}>
|
||||||
|
<FormattedText
|
||||||
|
id='agents.generating'
|
||||||
|
defaultMessage='Generating response...'
|
||||||
|
style={styles.precontentText}
|
||||||
|
/>
|
||||||
|
<StreamingIndicator/>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={styles.messageContainer}>
|
||||||
|
{displayMessage ? (
|
||||||
|
<Markdown
|
||||||
|
baseTextStyle={styles.messageText}
|
||||||
|
value={displayMessage}
|
||||||
|
theme={theme}
|
||||||
|
location={location}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{isGenerating && !isPrecontent && (
|
||||||
|
<StreamingIndicator/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{toolCalls.length > 0 && (
|
||||||
|
<ToolApprovalSet
|
||||||
|
postId={post.id}
|
||||||
|
toolCalls={toolCalls}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{annotations.length > 0 && (
|
||||||
|
<CitationsList annotations={annotations}/>
|
||||||
|
)}
|
||||||
|
{(showStopButton || showRegenerateButton) && (
|
||||||
|
<ControlsBar
|
||||||
|
showStopButton={showStopButton}
|
||||||
|
showRegenerateButton={showRegenerateButton}
|
||||||
|
onStop={handleStop}
|
||||||
|
onRegenerate={handleRegenerate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AgentPost;
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import React, {useEffect} from 'react';
|
||||||
|
import {View} from 'react-native';
|
||||||
|
import Animated, {
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withRepeat,
|
||||||
|
withSequence,
|
||||||
|
withTiming,
|
||||||
|
cancelAnimation,
|
||||||
|
} from 'react-native-reanimated';
|
||||||
|
|
||||||
|
import {useTheme} from '@context/theme';
|
||||||
|
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||||
|
|
||||||
|
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||||
|
container: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 4,
|
||||||
|
minHeight: 20,
|
||||||
|
},
|
||||||
|
cursor: {
|
||||||
|
width: 2,
|
||||||
|
height: 16,
|
||||||
|
borderRadius: 1,
|
||||||
|
marginLeft: 2,
|
||||||
|
backgroundColor: theme.centerChannelColor,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Animated indicator shown while agent is generating a response
|
||||||
|
* Shows a pulsing cursor for mobile-optimized UX
|
||||||
|
*/
|
||||||
|
const StreamingIndicator = () => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const styles = getStyleSheet(theme);
|
||||||
|
const opacity = useSharedValue(0.3);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
opacity.value = withRepeat(
|
||||||
|
withSequence(
|
||||||
|
withTiming(1, {duration: 800}),
|
||||||
|
withTiming(0.3, {duration: 800}),
|
||||||
|
),
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimation(opacity);
|
||||||
|
};
|
||||||
|
}, [opacity]);
|
||||||
|
|
||||||
|
const animatedStyle = useAnimatedStyle(() => ({
|
||||||
|
opacity: opacity.value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Animated.View style={[styles.cursor, animatedStyle]}/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StreamingIndicator;
|
||||||
185
app/products/agents/components/citations_list/index.tsx
Normal file
185
app/products/agents/components/citations_list/index.tsx
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {TOUCH_TARGET_SIZE} from '@agents/constants';
|
||||||
|
import React, {useCallback, useState} from 'react';
|
||||||
|
import {Text, TouchableOpacity, View} from 'react-native';
|
||||||
|
import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
|
||||||
|
|
||||||
|
import CompassIcon from '@components/compass_icon';
|
||||||
|
import FormattedText from '@components/formatted_text';
|
||||||
|
import {useTheme} from '@context/theme';
|
||||||
|
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||||
|
import {getUrlDomain, tryOpenURL} from '@utils/url';
|
||||||
|
|
||||||
|
import type {Annotation} from '@agents/types';
|
||||||
|
|
||||||
|
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||||
|
return {
|
||||||
|
container: {
|
||||||
|
marginTop: 12,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: changeOpacity(theme.centerChannelColor, 0.12),
|
||||||
|
paddingTop: 12,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingVertical: 8,
|
||||||
|
minHeight: TOUCH_TARGET_SIZE,
|
||||||
|
},
|
||||||
|
headerLeft: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
headerText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: changeOpacity(theme.centerChannelColor, 0.72),
|
||||||
|
marginLeft: 8,
|
||||||
|
},
|
||||||
|
citationsList: {
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
citationItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 12,
|
||||||
|
marginBottom: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
backgroundColor: changeOpacity(theme.centerChannelColor, 0.04),
|
||||||
|
minHeight: TOUCH_TARGET_SIZE,
|
||||||
|
},
|
||||||
|
citationIcon: {
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
borderRadius: 16,
|
||||||
|
backgroundColor: changeOpacity(theme.linkColor, 0.08),
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
citationContent: {
|
||||||
|
flex: 1,
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
citationTitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: theme.centerChannelColor,
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
citationUrl: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: changeOpacity(theme.centerChannelColor, 0.64),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
interface CitationsListProps {
|
||||||
|
annotations: Annotation[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Component to display a collapsible list of citations below the agent response
|
||||||
|
*/
|
||||||
|
const CitationsList = ({annotations}: CitationsListProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const styles = getStyleSheet(theme);
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
const animatedHeight = useSharedValue(0);
|
||||||
|
const opacity = useSharedValue(0);
|
||||||
|
|
||||||
|
const handleToggle = useCallback(() => {
|
||||||
|
const newExpanded = !isExpanded;
|
||||||
|
setIsExpanded(newExpanded);
|
||||||
|
animatedHeight.value = withTiming(newExpanded ? 1 : 0, {duration: 250});
|
||||||
|
opacity.value = withTiming(newExpanded ? 1 : 0, {duration: 250});
|
||||||
|
}, [isExpanded, animatedHeight, opacity]);
|
||||||
|
|
||||||
|
const handleCitationPress = useCallback((url: string) => {
|
||||||
|
if (url) {
|
||||||
|
tryOpenURL(url);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const listAnimatedStyle = useAnimatedStyle(() => ({
|
||||||
|
opacity: opacity.value,
|
||||||
|
maxHeight: animatedHeight.value === 0 ? 0 : undefined,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleToggle}
|
||||||
|
style={styles.header}
|
||||||
|
testID='citations.list.toggle'
|
||||||
|
>
|
||||||
|
<View style={styles.headerLeft}>
|
||||||
|
<CompassIcon
|
||||||
|
name='link-variant'
|
||||||
|
size={16}
|
||||||
|
color={changeOpacity(theme.centerChannelColor, 0.64)}
|
||||||
|
/>
|
||||||
|
<FormattedText
|
||||||
|
id='agents.citations.title'
|
||||||
|
defaultMessage='Sources ({count})'
|
||||||
|
values={{count: annotations.length}}
|
||||||
|
style={styles.headerText}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<CompassIcon
|
||||||
|
name={isExpanded ? 'chevron-up' : 'chevron-down'}
|
||||||
|
size={20}
|
||||||
|
color={changeOpacity(theme.centerChannelColor, 0.64)}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{isExpanded && (
|
||||||
|
<Animated.View style={[styles.citationsList, listAnimatedStyle]}>
|
||||||
|
{annotations.map((annotation, idx) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={`citation-${annotation.index}-${idx}`}
|
||||||
|
onPress={() => handleCitationPress(annotation.url)}
|
||||||
|
style={styles.citationItem}
|
||||||
|
testID={`citations.list.item.${annotation.index}`}
|
||||||
|
>
|
||||||
|
<View style={styles.citationIcon}>
|
||||||
|
<CompassIcon
|
||||||
|
name='link-variant'
|
||||||
|
size={14}
|
||||||
|
color={theme.linkColor}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={styles.citationContent}>
|
||||||
|
<Text
|
||||||
|
style={styles.citationTitle}
|
||||||
|
numberOfLines={2}
|
||||||
|
>
|
||||||
|
{annotation.title || getUrlDomain(annotation.url)}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={styles.citationUrl}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{getUrlDomain(annotation.url)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<CompassIcon
|
||||||
|
name='open-in-new'
|
||||||
|
size={16}
|
||||||
|
color={changeOpacity(theme.centerChannelColor, 0.56)}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CitationsList;
|
||||||
136
app/products/agents/components/controls_bar/index.tsx
Normal file
136
app/products/agents/components/controls_bar/index.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import React, {useCallback} from 'react';
|
||||||
|
import {useIntl} from 'react-intl';
|
||||||
|
import {Alert, TouchableOpacity, View} from 'react-native';
|
||||||
|
|
||||||
|
import CompassIcon from '@components/compass_icon';
|
||||||
|
import FormattedText from '@components/formatted_text';
|
||||||
|
import {useTheme} from '@context/theme';
|
||||||
|
import {usePreventDoubleTap} from '@hooks/utils';
|
||||||
|
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||||
|
|
||||||
|
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||||
|
return {
|
||||||
|
container: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 8,
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
backgroundColor: changeOpacity(theme.centerChannelColor, 0.08),
|
||||||
|
borderRadius: 4,
|
||||||
|
paddingVertical: 8,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
lineHeight: 16,
|
||||||
|
color: changeOpacity(theme.centerChannelColor, 0.64),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
interface ControlsBarProps {
|
||||||
|
showStopButton: boolean;
|
||||||
|
showRegenerateButton: boolean;
|
||||||
|
onStop: () => void;
|
||||||
|
onRegenerate: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controls bar for agent posts with stop and regenerate buttons
|
||||||
|
*/
|
||||||
|
const ControlsBar = ({
|
||||||
|
showStopButton,
|
||||||
|
showRegenerateButton,
|
||||||
|
onStop,
|
||||||
|
onRegenerate,
|
||||||
|
}: ControlsBarProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const styles = getStyleSheet(theme);
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const handleStop = usePreventDoubleTap(onStop);
|
||||||
|
|
||||||
|
const handleRegenerate = usePreventDoubleTap(useCallback(() => {
|
||||||
|
Alert.alert(
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'agents.regenerate.confirm_title',
|
||||||
|
defaultMessage: 'Regenerate Response',
|
||||||
|
}),
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'agents.regenerate.confirm_message',
|
||||||
|
defaultMessage: 'This will clear the current response and generate a new one. Continue?',
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: intl.formatMessage({
|
||||||
|
id: 'agents.regenerate.cancel',
|
||||||
|
defaultMessage: 'Cancel',
|
||||||
|
}),
|
||||||
|
style: 'cancel',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: intl.formatMessage({
|
||||||
|
id: 'agents.regenerate.confirm',
|
||||||
|
defaultMessage: 'Regenerate',
|
||||||
|
}),
|
||||||
|
onPress: onRegenerate,
|
||||||
|
style: 'destructive',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}, [intl, onRegenerate]));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{showStopButton && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleStop}
|
||||||
|
style={styles.button}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
testID='agents.controls_bar.stop_button'
|
||||||
|
>
|
||||||
|
<CompassIcon
|
||||||
|
name='close-circle-outline'
|
||||||
|
size={12}
|
||||||
|
color={changeOpacity(theme.centerChannelColor, 0.64)}
|
||||||
|
/>
|
||||||
|
<FormattedText
|
||||||
|
id='agents.controls.stop'
|
||||||
|
defaultMessage='Stop Generating'
|
||||||
|
style={styles.buttonText}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
{showRegenerateButton && (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleRegenerate}
|
||||||
|
style={styles.button}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
testID='agents.controls_bar.regenerate_button'
|
||||||
|
>
|
||||||
|
<CompassIcon
|
||||||
|
name='refresh'
|
||||||
|
size={12}
|
||||||
|
color={changeOpacity(theme.centerChannelColor, 0.64)}
|
||||||
|
/>
|
||||||
|
<FormattedText
|
||||||
|
id='agents.controls.regenerate'
|
||||||
|
defaultMessage='Regenerate'
|
||||||
|
style={styles.buttonText}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ControlsBar;
|
||||||
135
app/products/agents/components/reasoning_display/index.tsx
Normal file
135
app/products/agents/components/reasoning_display/index.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {TOUCH_TARGET_SIZE} from '@agents/constants';
|
||||||
|
import React, {useCallback, useState} from 'react';
|
||||||
|
import {Text, TouchableOpacity, View} from 'react-native';
|
||||||
|
import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
|
||||||
|
|
||||||
|
import CompassIcon from '@components/compass_icon';
|
||||||
|
import FormattedText from '@components/formatted_text';
|
||||||
|
import {useTheme} from '@context/theme';
|
||||||
|
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||||
|
|
||||||
|
import LoadingSpinner from './loading_spinner';
|
||||||
|
|
||||||
|
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||||
|
return {
|
||||||
|
minimalContainer: {
|
||||||
|
marginBottom: 4,
|
||||||
|
minHeight: TOUCH_TARGET_SIZE,
|
||||||
|
},
|
||||||
|
minimalContent: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
paddingVertical: 12,
|
||||||
|
},
|
||||||
|
minimalText: {
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
color: changeOpacity(theme.centerChannelColor, 0.64),
|
||||||
|
},
|
||||||
|
expandedContainer: {
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
expandedHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
marginBottom: 12,
|
||||||
|
minHeight: TOUCH_TARGET_SIZE,
|
||||||
|
paddingVertical: 12,
|
||||||
|
},
|
||||||
|
expandedHeaderText: {
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
color: changeOpacity(theme.centerChannelColor, 0.64),
|
||||||
|
},
|
||||||
|
reasoningContentContainer: {
|
||||||
|
backgroundColor: changeOpacity(theme.centerChannelColor, 0.02),
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: changeOpacity(theme.centerChannelColor, 0.08),
|
||||||
|
borderRadius: 8,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
reasoningContent: {
|
||||||
|
maxHeight: 600,
|
||||||
|
},
|
||||||
|
reasoningText: {
|
||||||
|
padding: 16,
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 22,
|
||||||
|
color: changeOpacity(theme.centerChannelColor, 0.8),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
interface ReasoningDisplayProps {
|
||||||
|
reasoningSummary: string;
|
||||||
|
isReasoningLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display component for agent reasoning summaries
|
||||||
|
* Shows collapsible section with reasoning text and loading state
|
||||||
|
*/
|
||||||
|
const ReasoningDisplay = ({reasoningSummary, isReasoningLoading}: ReasoningDisplayProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const styles = getStyleSheet(theme);
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
const rotation = useSharedValue(0);
|
||||||
|
const contentOpacity = useSharedValue(0);
|
||||||
|
|
||||||
|
const handleToggle = useCallback(() => {
|
||||||
|
const newExpanded = !isExpanded;
|
||||||
|
setIsExpanded(newExpanded);
|
||||||
|
rotation.value = withTiming(newExpanded ? 90 : 0, {duration: 200});
|
||||||
|
contentOpacity.value = withTiming(newExpanded ? 1 : 0, {duration: 250});
|
||||||
|
}, [isExpanded, rotation, contentOpacity]);
|
||||||
|
|
||||||
|
const chevronAnimatedStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{rotate: `${rotation.value}deg`}],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const contentAnimatedStyle = useAnimatedStyle(() => ({
|
||||||
|
opacity: contentOpacity.value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={isExpanded ? styles.expandedContainer : styles.minimalContainer}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleToggle}
|
||||||
|
style={isExpanded ? styles.expandedHeader : styles.minimalContent}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Animated.View style={chevronAnimatedStyle}>
|
||||||
|
<CompassIcon
|
||||||
|
name='chevron-right'
|
||||||
|
size={16}
|
||||||
|
color={changeOpacity(theme.centerChannelColor, 0.64)}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
{isReasoningLoading && (
|
||||||
|
<LoadingSpinner/>
|
||||||
|
)}
|
||||||
|
<FormattedText
|
||||||
|
id='agents.reasoning.thinking'
|
||||||
|
defaultMessage='Thinking'
|
||||||
|
style={isExpanded ? styles.expandedHeaderText : styles.minimalText}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
{isExpanded && reasoningSummary ? (
|
||||||
|
<Animated.View style={[styles.reasoningContentContainer, contentAnimatedStyle]}>
|
||||||
|
<View style={styles.reasoningContent}>
|
||||||
|
<Text style={styles.reasoningText}>
|
||||||
|
{reasoningSummary}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ReasoningDisplay;
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import React, {useEffect} from 'react';
|
||||||
|
import {View} from 'react-native';
|
||||||
|
import Animated, {
|
||||||
|
useAnimatedStyle,
|
||||||
|
useSharedValue,
|
||||||
|
withRepeat,
|
||||||
|
withTiming,
|
||||||
|
Easing,
|
||||||
|
cancelAnimation,
|
||||||
|
} from 'react-native-reanimated';
|
||||||
|
|
||||||
|
import {useTheme} from '@context/theme';
|
||||||
|
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||||
|
|
||||||
|
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
|
||||||
|
container: {
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
},
|
||||||
|
spinner: {
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderRadius: 7,
|
||||||
|
borderStyle: 'solid',
|
||||||
|
borderColor: changeOpacity(theme.centerChannelColor, 0.16),
|
||||||
|
borderTopColor: changeOpacity(theme.centerChannelColor, 0.64),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Animated loading spinner for reasoning generation
|
||||||
|
* Shows a rotating circular indicator
|
||||||
|
*/
|
||||||
|
const LoadingSpinner = () => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const styles = getStyleSheet(theme);
|
||||||
|
const rotation = useSharedValue(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
rotation.value = withRepeat(
|
||||||
|
withTiming(360, {duration: 1000, easing: Easing.linear}),
|
||||||
|
-1,
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimation(rotation);
|
||||||
|
};
|
||||||
|
}, [rotation]);
|
||||||
|
|
||||||
|
const animatedStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{rotate: `${rotation.value}deg`}],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Animated.View style={[styles.spinner, animatedStyle]}/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoadingSpinner;
|
||||||
214
app/products/agents/components/tool_approval_set/index.tsx
Normal file
214
app/products/agents/components/tool_approval_set/index.tsx
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {submitToolApproval} from '@agents/actions/remote/tool_approval';
|
||||||
|
import {type ToolCall, ToolCallStatus} from '@agents/types';
|
||||||
|
import React, {useCallback, useEffect, useState} from 'react';
|
||||||
|
import {View} from 'react-native';
|
||||||
|
|
||||||
|
import FormattedText from '@components/formatted_text';
|
||||||
|
import Loading from '@components/loading';
|
||||||
|
import {useServerUrl} from '@context/server';
|
||||||
|
import {useTheme} from '@context/theme';
|
||||||
|
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||||
|
|
||||||
|
import ToolCard from '../tool_card';
|
||||||
|
|
||||||
|
interface ToolApprovalSetProps {
|
||||||
|
postId: string;
|
||||||
|
toolCalls: ToolCall[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolDecision = {
|
||||||
|
[toolId: string]: boolean | null; // true = approved, false = rejected, null = undecided
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||||
|
return {
|
||||||
|
container: {
|
||||||
|
marginTop: 8,
|
||||||
|
marginBottom: 12,
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
statusBar: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
padding: 12,
|
||||||
|
marginTop: 8,
|
||||||
|
backgroundColor: changeOpacity(theme.centerChannelColor, 0.04),
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
statusText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: changeOpacity(theme.centerChannelColor, 0.64),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Container component for displaying and managing tool approval requests
|
||||||
|
*/
|
||||||
|
const ToolApprovalSet = ({postId, toolCalls}: ToolApprovalSetProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const styles = getStyleSheet(theme);
|
||||||
|
const serverUrl = useServerUrl();
|
||||||
|
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [expandedTools, setExpandedTools] = useState<Record<string, boolean>>({});
|
||||||
|
const [toolDecisions, setToolDecisions] = useState<ToolDecision>({});
|
||||||
|
|
||||||
|
// Clear local decisions when tool status changes from Pending to something else
|
||||||
|
useEffect(() => {
|
||||||
|
// Keep only decisions for tools that are still pending
|
||||||
|
const filterPendingDecisions = (decisions: ToolDecision): ToolDecision => {
|
||||||
|
const updated: ToolDecision = {};
|
||||||
|
const prevToolIds = Object.keys(decisions);
|
||||||
|
|
||||||
|
for (const toolId of prevToolIds) {
|
||||||
|
const tool = toolCalls.find((t) => t.id === toolId);
|
||||||
|
if (tool && tool.status === ToolCallStatus.Pending) {
|
||||||
|
updated[toolId] = decisions[toolId];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
};
|
||||||
|
|
||||||
|
setToolDecisions((prev) => {
|
||||||
|
const updated = filterPendingDecisions(prev);
|
||||||
|
const updatedCount = Object.keys(updated).length;
|
||||||
|
const prevCount = Object.keys(prev).length;
|
||||||
|
return updatedCount === prevCount ? prev : updated;
|
||||||
|
});
|
||||||
|
}, [toolCalls]);
|
||||||
|
|
||||||
|
const submitDecisions = useCallback(async (decisions: ToolDecision) => {
|
||||||
|
const approvedToolIds = Object.entries(decisions).
|
||||||
|
filter(([, isApproved]) => isApproved).
|
||||||
|
map(([id]) => id);
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
const {error} = await submitToolApproval(serverUrl, postId, approvedToolIds);
|
||||||
|
|
||||||
|
// Reset submitting state regardless of success/error
|
||||||
|
// On error, user can try again. On success, backend updates via POST_EDITED
|
||||||
|
setIsSubmitting(false);
|
||||||
|
return !error;
|
||||||
|
}, [serverUrl, postId]);
|
||||||
|
|
||||||
|
const handleToolDecision = useCallback(async (toolId: string, approved: boolean) => {
|
||||||
|
if (isSubmitting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedDecisions = {
|
||||||
|
...toolDecisions,
|
||||||
|
[toolId]: approved,
|
||||||
|
};
|
||||||
|
setToolDecisions(updatedDecisions);
|
||||||
|
|
||||||
|
// Check if there are still undecided tools
|
||||||
|
const hasUndecided = toolCalls.some((tool) => {
|
||||||
|
return !(tool.id in updatedDecisions) || updatedDecisions[tool.id] === null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hasUndecided) {
|
||||||
|
await submitDecisions(updatedDecisions);
|
||||||
|
}
|
||||||
|
}, [isSubmitting, toolDecisions, toolCalls, submitDecisions]);
|
||||||
|
|
||||||
|
const handleApprove = useCallback((toolId: string) => {
|
||||||
|
handleToolDecision(toolId, true);
|
||||||
|
}, [handleToolDecision]);
|
||||||
|
|
||||||
|
const handleReject = useCallback((toolId: string) => {
|
||||||
|
handleToolDecision(toolId, false);
|
||||||
|
}, [handleToolDecision]);
|
||||||
|
|
||||||
|
const toggleCollapse = useCallback((toolId: string) => {
|
||||||
|
const tool = toolCalls.find((t) => t.id === toolId);
|
||||||
|
const defaultExpanded = tool?.status === ToolCallStatus.Pending;
|
||||||
|
setExpandedTools((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[toolId]: !(prev[toolId] ?? defaultExpanded),
|
||||||
|
}));
|
||||||
|
}, [toolCalls]);
|
||||||
|
|
||||||
|
if (toolCalls.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get pending tool calls
|
||||||
|
const pendingToolCalls = toolCalls.filter((call) => call.status === ToolCallStatus.Pending);
|
||||||
|
|
||||||
|
// Get processed tool calls
|
||||||
|
const processedToolCalls = toolCalls.filter((call) => call.status !== ToolCallStatus.Pending);
|
||||||
|
|
||||||
|
// Calculate how many pending tools haven't been decided yet
|
||||||
|
const undecidedCount = pendingToolCalls.filter(
|
||||||
|
(tool) => !(tool.id in toolDecisions),
|
||||||
|
).length;
|
||||||
|
|
||||||
|
// Helper to compute if a tool should be collapsed
|
||||||
|
const isToolCollapsed = (tool: ToolCall) => {
|
||||||
|
const defaultExpanded = tool.status === ToolCallStatus.Pending;
|
||||||
|
return !(expandedTools[tool.id] ?? defaultExpanded);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{pendingToolCalls.map((tool) => (
|
||||||
|
<ToolCard
|
||||||
|
key={tool.id}
|
||||||
|
tool={tool}
|
||||||
|
isCollapsed={isToolCollapsed(tool)}
|
||||||
|
isProcessing={isSubmitting}
|
||||||
|
localDecision={toolDecisions[tool.id]}
|
||||||
|
onToggleCollapse={toggleCollapse}
|
||||||
|
onApprove={handleApprove}
|
||||||
|
onReject={handleReject}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{processedToolCalls.map((tool) => (
|
||||||
|
<ToolCard
|
||||||
|
key={tool.id}
|
||||||
|
tool={tool}
|
||||||
|
isCollapsed={isToolCollapsed(tool)}
|
||||||
|
isProcessing={false}
|
||||||
|
onToggleCollapse={toggleCollapse}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Only show status bar for multiple pending tools */}
|
||||||
|
{pendingToolCalls.length > 1 && isSubmitting && (
|
||||||
|
<View style={styles.statusBar}>
|
||||||
|
<Loading
|
||||||
|
size='small'
|
||||||
|
color={changeOpacity(theme.centerChannelColor, 0.64)}
|
||||||
|
/>
|
||||||
|
<FormattedText
|
||||||
|
id='agents.tool_call.submitting'
|
||||||
|
defaultMessage='Submitting...'
|
||||||
|
style={styles.statusText}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Only show status counter for multiple pending tools that haven't been submitted yet */}
|
||||||
|
{pendingToolCalls.length > 1 && undecidedCount > 0 && !isSubmitting && (
|
||||||
|
<View style={styles.statusBar}>
|
||||||
|
<FormattedText
|
||||||
|
id='agents.tool_call.pending_decisions'
|
||||||
|
defaultMessage='{count, plural, =0 {All tools decided} one {# tool needs a decision} other {# tools need decisions}}'
|
||||||
|
values={{count: undecidedCount}}
|
||||||
|
style={styles.statusText}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ToolApprovalSet;
|
||||||
363
app/products/agents/components/tool_card/index.tsx
Normal file
363
app/products/agents/components/tool_card/index.tsx
Normal file
|
|
@ -0,0 +1,363 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {ToolCallStatus, type ToolCall} from '@agents/types';
|
||||||
|
import React, {useCallback, useMemo} from 'react';
|
||||||
|
import {Text, TouchableOpacity, View} from 'react-native';
|
||||||
|
import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
|
||||||
|
|
||||||
|
import CompassIcon from '@components/compass_icon';
|
||||||
|
import FormattedText from '@components/formatted_text';
|
||||||
|
import Loading from '@components/loading';
|
||||||
|
import Markdown from '@components/markdown';
|
||||||
|
import {Screens} from '@constants';
|
||||||
|
import {useTheme} from '@context/theme';
|
||||||
|
import {safeParseJSON} from '@utils/helpers';
|
||||||
|
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||||
|
|
||||||
|
interface ToolCardProps {
|
||||||
|
tool: ToolCall;
|
||||||
|
isCollapsed: boolean;
|
||||||
|
isProcessing: boolean;
|
||||||
|
localDecision?: boolean | null; // true = approved, false = rejected, null/undefined = undecided
|
||||||
|
onToggleCollapse: (toolId: string) => void;
|
||||||
|
onApprove?: (toolId: string) => void;
|
||||||
|
onReject?: (toolId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
|
||||||
|
return {
|
||||||
|
container: {
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
paddingVertical: 8,
|
||||||
|
},
|
||||||
|
chevronIcon: {
|
||||||
|
width: 12,
|
||||||
|
},
|
||||||
|
statusIcon: {
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
},
|
||||||
|
toolName: {
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
color: changeOpacity(theme.centerChannelColor, 0.75),
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
argumentsContainer: {
|
||||||
|
marginLeft: 24,
|
||||||
|
},
|
||||||
|
markdownText: {
|
||||||
|
fontSize: 11,
|
||||||
|
lineHeight: 16,
|
||||||
|
color: changeOpacity(theme.centerChannelColor, 0.75),
|
||||||
|
},
|
||||||
|
responseLabel: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
paddingTop: 8,
|
||||||
|
paddingLeft: 24,
|
||||||
|
},
|
||||||
|
responseLabelText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: 600,
|
||||||
|
lineHeight: 20,
|
||||||
|
color: changeOpacity(theme.centerChannelColor, 0.75),
|
||||||
|
},
|
||||||
|
resultContainer: {
|
||||||
|
marginLeft: 24,
|
||||||
|
},
|
||||||
|
statusContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
marginTop: 16,
|
||||||
|
paddingLeft: 24,
|
||||||
|
},
|
||||||
|
statusText: {
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
color: changeOpacity(theme.centerChannelColor, 0.75),
|
||||||
|
},
|
||||||
|
buttonContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 8,
|
||||||
|
marginTop: 4,
|
||||||
|
paddingLeft: 36,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
backgroundColor: changeOpacity(theme.buttonBg, 0.08),
|
||||||
|
borderRadius: 4,
|
||||||
|
paddingVertical: 8,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
buttonDisabled: {
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
lineHeight: 16,
|
||||||
|
color: theme.buttonBg,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Individual tool card component showing tool details and approval buttons
|
||||||
|
*/
|
||||||
|
const ToolCard = ({
|
||||||
|
tool,
|
||||||
|
isCollapsed,
|
||||||
|
isProcessing,
|
||||||
|
localDecision,
|
||||||
|
onToggleCollapse,
|
||||||
|
onApprove,
|
||||||
|
onReject,
|
||||||
|
}: ToolCardProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const styles = getStyleSheet(theme);
|
||||||
|
const contentOpacity = useSharedValue(isCollapsed ? 0 : 1);
|
||||||
|
const chevronRotation = useSharedValue(isCollapsed ? 0 : 90);
|
||||||
|
|
||||||
|
const isPending = tool.status === ToolCallStatus.Pending;
|
||||||
|
const hasLocalDecision = localDecision !== undefined && localDecision !== null;
|
||||||
|
const isSuccess = tool.status === ToolCallStatus.Success;
|
||||||
|
const isError = tool.status === ToolCallStatus.Error;
|
||||||
|
const isRejected = tool.status === ToolCallStatus.Rejected;
|
||||||
|
|
||||||
|
// Convert underscores to spaces and capitalize first letter of each word
|
||||||
|
const displayName = useMemo(() => {
|
||||||
|
return tool.name.
|
||||||
|
replace(/_/g, ' ').
|
||||||
|
replace(/\b\w/g, (char) => char.toUpperCase());
|
||||||
|
}, [tool.name]);
|
||||||
|
|
||||||
|
// Render arguments as JSON code block
|
||||||
|
const argumentsMarkdown = useMemo(() => {
|
||||||
|
return `\`\`\`json\n${JSON.stringify(tool.arguments, null, 2)}\n\`\`\``;
|
||||||
|
}, [tool.arguments]);
|
||||||
|
|
||||||
|
// Render result as code block - try to detect if it's JSON
|
||||||
|
const resultMarkdown = useMemo(() => {
|
||||||
|
if (!tool.result) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = safeParseJSON(tool.result);
|
||||||
|
if (typeof parsed === 'object' && parsed !== null) {
|
||||||
|
return `\`\`\`json\n${tool.result}\n\`\`\``;
|
||||||
|
}
|
||||||
|
return `\`\`\`\n${tool.result}\n\`\`\``;
|
||||||
|
}, [tool.result]);
|
||||||
|
|
||||||
|
const handleToggle = useCallback(() => {
|
||||||
|
const newCollapsed = !isCollapsed;
|
||||||
|
contentOpacity.value = withTiming(newCollapsed ? 0 : 1, {duration: 200});
|
||||||
|
chevronRotation.value = withTiming(newCollapsed ? 0 : 90, {duration: 200});
|
||||||
|
onToggleCollapse(tool.id);
|
||||||
|
}, [isCollapsed, contentOpacity, chevronRotation, onToggleCollapse, tool.id]);
|
||||||
|
|
||||||
|
const handleApprove = useCallback(() => {
|
||||||
|
onApprove?.(tool.id);
|
||||||
|
}, [onApprove, tool.id]);
|
||||||
|
|
||||||
|
const handleReject = useCallback(() => {
|
||||||
|
onReject?.(tool.id);
|
||||||
|
}, [onReject, tool.id]);
|
||||||
|
|
||||||
|
const chevronAnimatedStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{rotate: `${chevronRotation.value}deg`}],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const contentAnimatedStyle = useAnimatedStyle(() => ({
|
||||||
|
opacity: contentOpacity.value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Determine icon based on status
|
||||||
|
const getStatusIcon = () => {
|
||||||
|
if (isPending) {
|
||||||
|
return (
|
||||||
|
<Loading
|
||||||
|
size='small'
|
||||||
|
color={changeOpacity(theme.centerChannelColor, 0.64)}
|
||||||
|
containerStyle={styles.statusIcon}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSuccess) {
|
||||||
|
return (
|
||||||
|
<CompassIcon
|
||||||
|
name='check-circle'
|
||||||
|
size={12}
|
||||||
|
color={theme.onlineIndicator}
|
||||||
|
style={styles.statusIcon}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<CompassIcon
|
||||||
|
name='alert-circle-outline'
|
||||||
|
size={12}
|
||||||
|
color={theme.errorTextColor}
|
||||||
|
style={styles.statusIcon}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRejected) {
|
||||||
|
return (
|
||||||
|
<CompassIcon
|
||||||
|
name='close-circle-outline'
|
||||||
|
size={12}
|
||||||
|
color={theme.dndIndicator}
|
||||||
|
style={styles.statusIcon}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleToggle}
|
||||||
|
style={styles.header}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Animated.View style={[styles.chevronIcon, chevronAnimatedStyle]}>
|
||||||
|
<CompassIcon
|
||||||
|
name='chevron-right'
|
||||||
|
size={16}
|
||||||
|
color={changeOpacity(theme.centerChannelColor, 0.56)}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
{getStatusIcon()}
|
||||||
|
<Text
|
||||||
|
style={styles.toolName}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{displayName}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{!isCollapsed && (
|
||||||
|
<Animated.View style={contentAnimatedStyle}>
|
||||||
|
<View style={styles.argumentsContainer}>
|
||||||
|
<Markdown
|
||||||
|
baseTextStyle={styles.markdownText}
|
||||||
|
value={argumentsMarkdown}
|
||||||
|
theme={theme}
|
||||||
|
location={Screens.CHANNEL}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{(isSuccess || isError) && resultMarkdown && (
|
||||||
|
<>
|
||||||
|
<View style={styles.responseLabel}>
|
||||||
|
{isSuccess && (
|
||||||
|
<CompassIcon
|
||||||
|
name='check-circle'
|
||||||
|
size={12}
|
||||||
|
color={theme.onlineIndicator}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isError && (
|
||||||
|
<CompassIcon
|
||||||
|
name='alert-circle-outline'
|
||||||
|
size={12}
|
||||||
|
color={theme.errorTextColor}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<FormattedText
|
||||||
|
id='agents.tool_call.response'
|
||||||
|
defaultMessage='Response'
|
||||||
|
style={styles.responseLabelText}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={styles.resultContainer}>
|
||||||
|
<Markdown
|
||||||
|
baseTextStyle={styles.markdownText}
|
||||||
|
value={resultMarkdown}
|
||||||
|
theme={theme}
|
||||||
|
location={Screens.CHANNEL}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isRejected && (
|
||||||
|
<View style={styles.statusContainer}>
|
||||||
|
<CompassIcon
|
||||||
|
name='close-circle-outline'
|
||||||
|
size={12}
|
||||||
|
color={theme.dndIndicator}
|
||||||
|
/>
|
||||||
|
<FormattedText
|
||||||
|
id='agents.tool_call.status.rejected'
|
||||||
|
defaultMessage='Rejected'
|
||||||
|
style={styles.statusText}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isPending && !hasLocalDecision && isProcessing && (
|
||||||
|
<View style={styles.statusContainer}>
|
||||||
|
<Loading
|
||||||
|
size='small'
|
||||||
|
color={changeOpacity(theme.centerChannelColor, 0.64)}
|
||||||
|
/>
|
||||||
|
<FormattedText
|
||||||
|
id='agents.tool_call.processing'
|
||||||
|
defaultMessage='Processing...'
|
||||||
|
style={styles.statusText}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isPending && !hasLocalDecision && !isProcessing && (
|
||||||
|
<View style={styles.buttonContainer}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleApprove}
|
||||||
|
disabled={isProcessing}
|
||||||
|
style={[styles.button, isProcessing && styles.buttonDisabled]}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<FormattedText
|
||||||
|
id='agents.tool_call.approve'
|
||||||
|
defaultMessage='Accept'
|
||||||
|
style={styles.buttonText}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleReject}
|
||||||
|
disabled={isProcessing}
|
||||||
|
style={[styles.button, isProcessing && styles.buttonDisabled]}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<FormattedText
|
||||||
|
id='agents.tool_call.reject'
|
||||||
|
defaultMessage='Reject'
|
||||||
|
style={styles.buttonText}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ToolCard;
|
||||||
33
app/products/agents/constants.ts
Normal file
33
app/products/agents/constants.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent post types
|
||||||
|
*/
|
||||||
|
export const AGENT_POST_TYPES = {
|
||||||
|
LLMBOT: 'custom_llmbot',
|
||||||
|
LLM_POSTBACK: 'custom_llm_postback',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimum touch target size for accessibility (44pt per iOS HIG)
|
||||||
|
*/
|
||||||
|
export const TOUCH_TARGET_SIZE = 44;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebSocket event name for agent post updates
|
||||||
|
*/
|
||||||
|
export const AGENT_WEBSOCKET_EVENT = 'custom_mattermost-ai_postupdate';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Control signal values from WebSocket messages
|
||||||
|
*/
|
||||||
|
export const CONTROL_SIGNALS = {
|
||||||
|
START: 'start',
|
||||||
|
END: 'end',
|
||||||
|
CANCEL: 'cancel',
|
||||||
|
REASONING_SUMMARY: 'reasoning_summary',
|
||||||
|
REASONING_SUMMARY_DONE: 'reasoning_summary_done',
|
||||||
|
TOOL_CALL: 'tool_call',
|
||||||
|
ANNOTATIONS: 'annotations',
|
||||||
|
} as const;
|
||||||
4
app/products/agents/store/index.ts
Normal file
4
app/products/agents/store/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
export {default as streamingStore, useStreamingState} from './streaming_store';
|
||||||
634
app/products/agents/store/streaming_store.test.ts
Normal file
634
app/products/agents/store/streaming_store.test.ts
Normal file
|
|
@ -0,0 +1,634 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {CONTROL_SIGNALS} from '@agents/constants';
|
||||||
|
import {ToolCallStatus, type PostUpdateWebsocketMessage, type ToolCall, type Annotation, type StreamingState} from '@agents/types';
|
||||||
|
|
||||||
|
describe('StreamingPostStore', () => {
|
||||||
|
let streamingStore: typeof import('./streaming_store').default;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetModules();
|
||||||
|
streamingStore = require('./streaming_store').default;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('startStreaming', () => {
|
||||||
|
it('should create initial state with precontent true', () => {
|
||||||
|
const postId = 'post123';
|
||||||
|
streamingStore.startStreaming(postId);
|
||||||
|
|
||||||
|
const state = streamingStore.getStreamingState(postId);
|
||||||
|
expect(state).toBeDefined();
|
||||||
|
expect(state?.postId).toBe(postId);
|
||||||
|
expect(state?.generating).toBe(true);
|
||||||
|
expect(state?.message).toBe('');
|
||||||
|
expect(state?.precontent).toBe(true);
|
||||||
|
expect(state?.reasoning).toBe('');
|
||||||
|
expect(state?.isReasoningLoading).toBe(false);
|
||||||
|
expect(state?.showReasoning).toBe(false);
|
||||||
|
expect(state?.toolCalls).toEqual([]);
|
||||||
|
expect(state?.annotations).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit state to observers', () => {
|
||||||
|
const postId = 'post123';
|
||||||
|
const receivedStates: Array<StreamingState | undefined> = [];
|
||||||
|
|
||||||
|
const subscription = streamingStore.observeStreamingState(postId).subscribe((state) => {
|
||||||
|
receivedStates.push(state);
|
||||||
|
});
|
||||||
|
|
||||||
|
streamingStore.startStreaming(postId);
|
||||||
|
|
||||||
|
// BehaviorSubject emits initial undefined, then the started state
|
||||||
|
expect(receivedStates).toHaveLength(2);
|
||||||
|
expect(receivedStates[0]).toBeUndefined();
|
||||||
|
expect(receivedStates[1]).toMatchObject({
|
||||||
|
postId,
|
||||||
|
generating: true,
|
||||||
|
message: '',
|
||||||
|
precontent: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateMessage', () => {
|
||||||
|
it('should update message and set precontent to false', () => {
|
||||||
|
const postId = 'post123';
|
||||||
|
streamingStore.startStreaming(postId);
|
||||||
|
|
||||||
|
streamingStore.updateMessage(postId, 'Hello world');
|
||||||
|
|
||||||
|
const state = streamingStore.getStreamingState(postId);
|
||||||
|
expect(state?.message).toBe('Hello world');
|
||||||
|
expect(state?.precontent).toBe(false);
|
||||||
|
expect(state?.generating).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit updated state to observers', () => {
|
||||||
|
const postId = 'post123';
|
||||||
|
streamingStore.startStreaming(postId);
|
||||||
|
|
||||||
|
const receivedStates: Array<StreamingState | undefined> = [];
|
||||||
|
const subscription = streamingStore.observeStreamingState(postId).subscribe((state) => {
|
||||||
|
receivedStates.push(state);
|
||||||
|
});
|
||||||
|
|
||||||
|
streamingStore.updateMessage(postId, 'Hello world');
|
||||||
|
|
||||||
|
// First emission is current state (from subscribe), second is update
|
||||||
|
expect(receivedStates).toHaveLength(2);
|
||||||
|
expect(receivedStates[1]).toMatchObject({
|
||||||
|
postId,
|
||||||
|
message: 'Hello world',
|
||||||
|
precontent: false,
|
||||||
|
generating: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should do nothing if post is not streaming', () => {
|
||||||
|
const postId = 'post123';
|
||||||
|
streamingStore.updateMessage(postId, 'Hello world');
|
||||||
|
|
||||||
|
const state = streamingStore.getStreamingState(postId);
|
||||||
|
expect(state).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('endStreaming', () => {
|
||||||
|
it('should preserve message but mark as not generating', () => {
|
||||||
|
const postId = 'post123';
|
||||||
|
streamingStore.startStreaming(postId);
|
||||||
|
streamingStore.updateMessage(postId, 'Hello world');
|
||||||
|
|
||||||
|
streamingStore.endStreaming(postId);
|
||||||
|
|
||||||
|
// State should be preserved with generating: false
|
||||||
|
const state = streamingStore.getStreamingState(postId);
|
||||||
|
expect(state).toBeDefined();
|
||||||
|
expect(state?.message).toBe('Hello world');
|
||||||
|
expect(state?.generating).toBe(false);
|
||||||
|
expect(state?.precontent).toBe(false);
|
||||||
|
expect(state?.isReasoningLoading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit preserved state and keep subject alive for reuse', () => {
|
||||||
|
const postId = 'post123';
|
||||||
|
streamingStore.startStreaming(postId);
|
||||||
|
streamingStore.updateMessage(postId, 'Hello world');
|
||||||
|
|
||||||
|
const receivedStates: Array<StreamingState | undefined> = [];
|
||||||
|
let completed = false;
|
||||||
|
|
||||||
|
const subscription = streamingStore.observeStreamingState(postId).subscribe({
|
||||||
|
next: (state) => receivedStates.push(state),
|
||||||
|
complete: () => {
|
||||||
|
completed = true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
streamingStore.endStreaming(postId);
|
||||||
|
|
||||||
|
// Should receive current state on subscribe, then state with generating: false
|
||||||
|
expect(receivedStates).toHaveLength(2);
|
||||||
|
expect(receivedStates[1]).toMatchObject({
|
||||||
|
postId,
|
||||||
|
message: 'Hello world',
|
||||||
|
generating: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subject should NOT complete - kept alive for potential reuse (regenerate)
|
||||||
|
expect(completed).toBe(false);
|
||||||
|
|
||||||
|
// Verify subject can receive new streaming updates (regenerate scenario)
|
||||||
|
streamingStore.startStreaming(postId);
|
||||||
|
expect(receivedStates).toHaveLength(3);
|
||||||
|
expect(receivedStates[2]).toMatchObject({postId, generating: true, precontent: true});
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should do nothing if post is not streaming', () => {
|
||||||
|
const postId = 'post123';
|
||||||
|
|
||||||
|
// Should not throw
|
||||||
|
expect(() => streamingStore.endStreaming(postId)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateReasoning', () => {
|
||||||
|
it('should update reasoning and set showReasoning to true', () => {
|
||||||
|
const postId = 'post123';
|
||||||
|
streamingStore.startStreaming(postId);
|
||||||
|
|
||||||
|
streamingStore.updateReasoning(postId, 'Analyzing the question...', true);
|
||||||
|
|
||||||
|
const state = streamingStore.getStreamingState(postId);
|
||||||
|
expect(state?.reasoning).toBe('Analyzing the question...');
|
||||||
|
expect(state?.isReasoningLoading).toBe(true);
|
||||||
|
expect(state?.showReasoning).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set generating to false when isLoading is true', () => {
|
||||||
|
const postId = 'post123';
|
||||||
|
streamingStore.startStreaming(postId);
|
||||||
|
|
||||||
|
streamingStore.updateReasoning(postId, 'Analyzing...', true);
|
||||||
|
|
||||||
|
const state = streamingStore.getStreamingState(postId);
|
||||||
|
expect(state?.generating).toBe(false);
|
||||||
|
expect(state?.precontent).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve generating state when isLoading is false', () => {
|
||||||
|
const postId = 'post123';
|
||||||
|
streamingStore.startStreaming(postId);
|
||||||
|
streamingStore.updateMessage(postId, 'Some text');
|
||||||
|
|
||||||
|
streamingStore.updateReasoning(postId, 'Reasoning complete', false);
|
||||||
|
|
||||||
|
const state = streamingStore.getStreamingState(postId);
|
||||||
|
expect(state?.generating).toBe(true);
|
||||||
|
expect(state?.isReasoningLoading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should do nothing if post is not streaming', () => {
|
||||||
|
const postId = 'post123';
|
||||||
|
streamingStore.updateReasoning(postId, 'Reasoning', true);
|
||||||
|
|
||||||
|
const state = streamingStore.getStreamingState(postId);
|
||||||
|
expect(state).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateToolCalls', () => {
|
||||||
|
it('should parse JSON and update tool calls', () => {
|
||||||
|
const postId = 'post123';
|
||||||
|
streamingStore.startStreaming(postId);
|
||||||
|
|
||||||
|
const toolCalls: ToolCall[] = [
|
||||||
|
{
|
||||||
|
id: 'tool1',
|
||||||
|
name: 'search',
|
||||||
|
description: 'Search for information',
|
||||||
|
arguments: {query: 'test'},
|
||||||
|
status: ToolCallStatus.Pending,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
streamingStore.updateToolCalls(postId, JSON.stringify(toolCalls));
|
||||||
|
|
||||||
|
const state = streamingStore.getStreamingState(postId);
|
||||||
|
expect(state?.toolCalls).toEqual(toolCalls);
|
||||||
|
expect(state?.precontent).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should silently handle JSON parse errors', () => {
|
||||||
|
const postId = 'post123';
|
||||||
|
streamingStore.startStreaming(postId);
|
||||||
|
|
||||||
|
streamingStore.updateToolCalls(postId, 'invalid json');
|
||||||
|
|
||||||
|
const state = streamingStore.getStreamingState(postId);
|
||||||
|
expect(state?.toolCalls).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create minimal state if post is not streaming', () => {
|
||||||
|
const postId = 'post123';
|
||||||
|
streamingStore.updateToolCalls(postId, '[]');
|
||||||
|
|
||||||
|
const state = streamingStore.getStreamingState(postId);
|
||||||
|
expect(state).toBeDefined();
|
||||||
|
expect(state?.toolCalls).toEqual([]);
|
||||||
|
expect(state?.generating).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateAnnotations', () => {
|
||||||
|
it('should parse JSON and update annotations', () => {
|
||||||
|
const postId = 'post123';
|
||||||
|
streamingStore.startStreaming(postId);
|
||||||
|
|
||||||
|
const annotations: Annotation[] = [
|
||||||
|
{
|
||||||
|
type: 'citation',
|
||||||
|
start_index: 0,
|
||||||
|
end_index: 10,
|
||||||
|
url: 'https://example.com',
|
||||||
|
title: 'Example',
|
||||||
|
index: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
streamingStore.updateAnnotations(postId, JSON.stringify(annotations));
|
||||||
|
|
||||||
|
const state = streamingStore.getStreamingState(postId);
|
||||||
|
expect(state?.annotations).toEqual(annotations);
|
||||||
|
expect(state?.precontent).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should silently handle JSON parse errors', () => {
|
||||||
|
const postId = 'post123';
|
||||||
|
streamingStore.startStreaming(postId);
|
||||||
|
|
||||||
|
streamingStore.updateAnnotations(postId, 'invalid json');
|
||||||
|
|
||||||
|
const state = streamingStore.getStreamingState(postId);
|
||||||
|
expect(state?.annotations).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should do nothing if post is not streaming', () => {
|
||||||
|
const postId = 'post123';
|
||||||
|
streamingStore.updateAnnotations(postId, '[]');
|
||||||
|
|
||||||
|
const state = streamingStore.getStreamingState(postId);
|
||||||
|
expect(state).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handleWebSocketMessage', () => {
|
||||||
|
it('should handle START control signal', () => {
|
||||||
|
const message: PostUpdateWebsocketMessage = {
|
||||||
|
post_id: 'post123',
|
||||||
|
control: CONTROL_SIGNALS.START,
|
||||||
|
};
|
||||||
|
|
||||||
|
streamingStore.handleWebSocketMessage(message);
|
||||||
|
|
||||||
|
const state = streamingStore.getStreamingState('post123');
|
||||||
|
expect(state).toBeDefined();
|
||||||
|
expect(state?.generating).toBe(true);
|
||||||
|
expect(state?.precontent).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle END control signal', () => {
|
||||||
|
const postId = 'post123';
|
||||||
|
streamingStore.startStreaming(postId);
|
||||||
|
streamingStore.updateMessage(postId, 'Hello');
|
||||||
|
|
||||||
|
const message: PostUpdateWebsocketMessage = {
|
||||||
|
post_id: postId,
|
||||||
|
control: CONTROL_SIGNALS.END,
|
||||||
|
};
|
||||||
|
|
||||||
|
streamingStore.handleWebSocketMessage(message);
|
||||||
|
|
||||||
|
// State should be preserved with generating: false
|
||||||
|
const state = streamingStore.getStreamingState(postId);
|
||||||
|
expect(state).toBeDefined();
|
||||||
|
expect(state?.message).toBe('Hello');
|
||||||
|
expect(state?.generating).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle CANCEL control signal', () => {
|
||||||
|
const postId = 'post123';
|
||||||
|
streamingStore.startStreaming(postId);
|
||||||
|
streamingStore.updateMessage(postId, 'Hello');
|
||||||
|
|
||||||
|
const message: PostUpdateWebsocketMessage = {
|
||||||
|
post_id: postId,
|
||||||
|
control: CONTROL_SIGNALS.CANCEL,
|
||||||
|
};
|
||||||
|
|
||||||
|
streamingStore.handleWebSocketMessage(message);
|
||||||
|
|
||||||
|
// State should be preserved with generating: false
|
||||||
|
const state = streamingStore.getStreamingState(postId);
|
||||||
|
expect(state).toBeDefined();
|
||||||
|
expect(state?.message).toBe('Hello');
|
||||||
|
expect(state?.generating).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle REASONING_SUMMARY control signal', () => {
|
||||||
|
const postId = 'post123';
|
||||||
|
streamingStore.startStreaming(postId);
|
||||||
|
|
||||||
|
const message: PostUpdateWebsocketMessage = {
|
||||||
|
post_id: postId,
|
||||||
|
control: CONTROL_SIGNALS.REASONING_SUMMARY,
|
||||||
|
reasoning: 'Thinking step 1...',
|
||||||
|
};
|
||||||
|
|
||||||
|
streamingStore.handleWebSocketMessage(message);
|
||||||
|
|
||||||
|
const state = streamingStore.getStreamingState(postId);
|
||||||
|
expect(state?.reasoning).toBe('Thinking step 1...');
|
||||||
|
expect(state?.isReasoningLoading).toBe(true);
|
||||||
|
expect(state?.showReasoning).toBe(true);
|
||||||
|
expect(state?.generating).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle REASONING_SUMMARY_DONE with reasoning text', () => {
|
||||||
|
const postId = 'post123';
|
||||||
|
streamingStore.startStreaming(postId);
|
||||||
|
streamingStore.updateReasoning(postId, 'Step 1', true);
|
||||||
|
|
||||||
|
const message: PostUpdateWebsocketMessage = {
|
||||||
|
post_id: postId,
|
||||||
|
control: CONTROL_SIGNALS.REASONING_SUMMARY_DONE,
|
||||||
|
reasoning: 'Final reasoning',
|
||||||
|
};
|
||||||
|
|
||||||
|
streamingStore.handleWebSocketMessage(message);
|
||||||
|
|
||||||
|
const state = streamingStore.getStreamingState(postId);
|
||||||
|
expect(state?.reasoning).toBe('Final reasoning');
|
||||||
|
expect(state?.isReasoningLoading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle REASONING_SUMMARY_DONE without reasoning text', () => {
|
||||||
|
const postId = 'post123';
|
||||||
|
streamingStore.startStreaming(postId);
|
||||||
|
streamingStore.updateReasoning(postId, 'Existing reasoning', true);
|
||||||
|
|
||||||
|
const message: PostUpdateWebsocketMessage = {
|
||||||
|
post_id: postId,
|
||||||
|
control: CONTROL_SIGNALS.REASONING_SUMMARY_DONE,
|
||||||
|
};
|
||||||
|
|
||||||
|
streamingStore.handleWebSocketMessage(message);
|
||||||
|
|
||||||
|
const state = streamingStore.getStreamingState(postId);
|
||||||
|
expect(state?.reasoning).toBe('Existing reasoning');
|
||||||
|
expect(state?.isReasoningLoading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle TOOL_CALL control signal', () => {
|
||||||
|
const postId = 'post123';
|
||||||
|
streamingStore.startStreaming(postId);
|
||||||
|
|
||||||
|
const toolCalls: ToolCall[] = [
|
||||||
|
{
|
||||||
|
id: 'tool1',
|
||||||
|
name: 'search',
|
||||||
|
description: 'Search',
|
||||||
|
arguments: {query: 'test'},
|
||||||
|
status: ToolCallStatus.Pending,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const message: PostUpdateWebsocketMessage = {
|
||||||
|
post_id: postId,
|
||||||
|
control: CONTROL_SIGNALS.TOOL_CALL,
|
||||||
|
tool_call: JSON.stringify(toolCalls),
|
||||||
|
};
|
||||||
|
|
||||||
|
streamingStore.handleWebSocketMessage(message);
|
||||||
|
|
||||||
|
const state = streamingStore.getStreamingState(postId);
|
||||||
|
expect(state?.toolCalls).toEqual(toolCalls);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle ANNOTATIONS control signal', () => {
|
||||||
|
const postId = 'post123';
|
||||||
|
streamingStore.startStreaming(postId);
|
||||||
|
|
||||||
|
const annotations: Annotation[] = [
|
||||||
|
{
|
||||||
|
type: 'citation',
|
||||||
|
start_index: 0,
|
||||||
|
end_index: 10,
|
||||||
|
url: 'https://example.com',
|
||||||
|
title: 'Example',
|
||||||
|
index: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const message: PostUpdateWebsocketMessage = {
|
||||||
|
post_id: postId,
|
||||||
|
control: CONTROL_SIGNALS.ANNOTATIONS,
|
||||||
|
annotations: JSON.stringify(annotations),
|
||||||
|
};
|
||||||
|
|
||||||
|
streamingStore.handleWebSocketMessage(message);
|
||||||
|
|
||||||
|
const state = streamingStore.getStreamingState(postId);
|
||||||
|
expect(state?.annotations).toEqual(annotations);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle message updates with next field', () => {
|
||||||
|
const postId = 'post123';
|
||||||
|
streamingStore.startStreaming(postId);
|
||||||
|
|
||||||
|
const message: PostUpdateWebsocketMessage = {
|
||||||
|
post_id: postId,
|
||||||
|
next: 'Hello from agent',
|
||||||
|
};
|
||||||
|
|
||||||
|
streamingStore.handleWebSocketMessage(message);
|
||||||
|
|
||||||
|
const state = streamingStore.getStreamingState(postId);
|
||||||
|
expect(state?.message).toBe('Hello from agent');
|
||||||
|
expect(state?.precontent).toBe(false);
|
||||||
|
expect(state?.generating).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should ignore messages without post_id', () => {
|
||||||
|
const message: PostUpdateWebsocketMessage = {
|
||||||
|
post_id: '',
|
||||||
|
next: 'Hello',
|
||||||
|
};
|
||||||
|
|
||||||
|
streamingStore.handleWebSocketMessage(message);
|
||||||
|
|
||||||
|
// Nothing should be created
|
||||||
|
expect(streamingStore.getStreamingState('')).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('observeStreamingState', () => {
|
||||||
|
it('should emit current state immediately on subscribe', () => {
|
||||||
|
const postId = 'post123';
|
||||||
|
streamingStore.startStreaming(postId);
|
||||||
|
|
||||||
|
let receivedState: StreamingState | undefined;
|
||||||
|
const subscription = streamingStore.observeStreamingState(postId).subscribe((state) => {
|
||||||
|
receivedState = state;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(receivedState).toBeDefined();
|
||||||
|
expect(receivedState?.postId).toBe(postId);
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit undefined for non-streaming post', () => {
|
||||||
|
const postId = 'nonexistent';
|
||||||
|
|
||||||
|
let receivedState: StreamingState | undefined = {postId: 'sentinel'} as StreamingState;
|
||||||
|
const subscription = streamingStore.observeStreamingState(postId).subscribe((state) => {
|
||||||
|
receivedState = state;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(receivedState).toBeUndefined();
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getStreamingState', () => {
|
||||||
|
it('should return state for streaming post', () => {
|
||||||
|
const postId = 'post123';
|
||||||
|
streamingStore.startStreaming(postId);
|
||||||
|
|
||||||
|
const state = streamingStore.getStreamingState(postId);
|
||||||
|
expect(state).toBeDefined();
|
||||||
|
expect(state?.postId).toBe(postId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined for non-streaming post', () => {
|
||||||
|
const state = streamingStore.getStreamingState('nonexistent');
|
||||||
|
expect(state).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isStreaming', () => {
|
||||||
|
it('should return true for streaming post', () => {
|
||||||
|
const postId = 'post123';
|
||||||
|
streamingStore.startStreaming(postId);
|
||||||
|
|
||||||
|
expect(streamingStore.isStreaming(postId)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for non-streaming post', () => {
|
||||||
|
expect(streamingStore.isStreaming('nonexistent')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('removePost', () => {
|
||||||
|
it('should remove streaming state and emit undefined', () => {
|
||||||
|
const postId = 'post123';
|
||||||
|
streamingStore.startStreaming(postId);
|
||||||
|
|
||||||
|
const receivedStates: Array<StreamingState | undefined> = [];
|
||||||
|
const subscription = streamingStore.observeStreamingState(postId).subscribe((state) => {
|
||||||
|
receivedStates.push(state);
|
||||||
|
});
|
||||||
|
|
||||||
|
streamingStore.removePost(postId);
|
||||||
|
|
||||||
|
expect(streamingStore.getStreamingState(postId)).toBeUndefined();
|
||||||
|
expect(receivedStates[receivedStates.length - 1]).toBeUndefined();
|
||||||
|
|
||||||
|
subscription.unsubscribe();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should do nothing if post is not streaming', () => {
|
||||||
|
const postId = 'post123';
|
||||||
|
|
||||||
|
// Should not throw
|
||||||
|
expect(() => streamingStore.removePost(postId)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('clear', () => {
|
||||||
|
it('should clear all streaming state', () => {
|
||||||
|
streamingStore.startStreaming('post1');
|
||||||
|
streamingStore.startStreaming('post2');
|
||||||
|
streamingStore.startStreaming('post3');
|
||||||
|
|
||||||
|
expect(streamingStore.isStreaming('post1')).toBe(true);
|
||||||
|
expect(streamingStore.isStreaming('post2')).toBe(true);
|
||||||
|
expect(streamingStore.isStreaming('post3')).toBe(true);
|
||||||
|
|
||||||
|
streamingStore.clear();
|
||||||
|
|
||||||
|
expect(streamingStore.isStreaming('post1')).toBe(false);
|
||||||
|
expect(streamingStore.isStreaming('post2')).toBe(false);
|
||||||
|
expect(streamingStore.isStreaming('post3')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should complete all subscriptions', () => {
|
||||||
|
streamingStore.startStreaming('post1');
|
||||||
|
streamingStore.startStreaming('post2');
|
||||||
|
|
||||||
|
let completedCount = 0;
|
||||||
|
const sub1 = streamingStore.observeStreamingState('post1').subscribe({
|
||||||
|
complete: () => completedCount++,
|
||||||
|
});
|
||||||
|
const sub2 = streamingStore.observeStreamingState('post2').subscribe({
|
||||||
|
complete: () => completedCount++,
|
||||||
|
});
|
||||||
|
|
||||||
|
streamingStore.clear();
|
||||||
|
|
||||||
|
expect(completedCount).toBe(2);
|
||||||
|
|
||||||
|
sub1.unsubscribe();
|
||||||
|
sub2.unsubscribe();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('multiple concurrent streams', () => {
|
||||||
|
it('should handle multiple posts streaming simultaneously', () => {
|
||||||
|
streamingStore.startStreaming('post1');
|
||||||
|
streamingStore.startStreaming('post2');
|
||||||
|
streamingStore.updateMessage('post1', 'Message 1');
|
||||||
|
streamingStore.updateMessage('post2', 'Message 2');
|
||||||
|
|
||||||
|
const state1 = streamingStore.getStreamingState('post1');
|
||||||
|
const state2 = streamingStore.getStreamingState('post2');
|
||||||
|
|
||||||
|
expect(state1?.message).toBe('Message 1');
|
||||||
|
expect(state2?.message).toBe('Message 2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle ending one stream while others continue', () => {
|
||||||
|
streamingStore.startStreaming('post1');
|
||||||
|
streamingStore.startStreaming('post2');
|
||||||
|
|
||||||
|
streamingStore.endStreaming('post1');
|
||||||
|
|
||||||
|
expect(streamingStore.isStreaming('post1')).toBe(false);
|
||||||
|
expect(streamingStore.isStreaming('post2')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
294
app/products/agents/store/streaming_store.ts
Normal file
294
app/products/agents/store/streaming_store.ts
Normal file
|
|
@ -0,0 +1,294 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {CONTROL_SIGNALS} from '@agents/constants';
|
||||||
|
import {type StreamingState, type PostUpdateWebsocketMessage} from '@agents/types';
|
||||||
|
import {useEffect, useState} from 'react';
|
||||||
|
import {BehaviorSubject, type Observable} from 'rxjs';
|
||||||
|
|
||||||
|
import {logWarning} from '@utils/log';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ephemeral store for managing streaming post state
|
||||||
|
* Uses RxJS BehaviorSubject for reactive state management
|
||||||
|
*/
|
||||||
|
class StreamingPostStore {
|
||||||
|
private streamingSubjects: Map<string, BehaviorSubject<StreamingState | undefined>> = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or create a BehaviorSubject for a post
|
||||||
|
*/
|
||||||
|
private getSubject(postId: string): BehaviorSubject<StreamingState | undefined> {
|
||||||
|
let subject = this.streamingSubjects.get(postId);
|
||||||
|
if (!subject) {
|
||||||
|
subject = new BehaviorSubject<StreamingState | undefined>(undefined);
|
||||||
|
this.streamingSubjects.set(postId, subject);
|
||||||
|
}
|
||||||
|
return subject;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start streaming for a post
|
||||||
|
*/
|
||||||
|
startStreaming(postId: string): void {
|
||||||
|
const state: StreamingState = {
|
||||||
|
postId,
|
||||||
|
generating: true,
|
||||||
|
message: '',
|
||||||
|
precontent: true, // Show "Starting..." state
|
||||||
|
reasoning: '',
|
||||||
|
isReasoningLoading: false,
|
||||||
|
showReasoning: false,
|
||||||
|
toolCalls: [],
|
||||||
|
annotations: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
this.getSubject(postId).next(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the streaming message
|
||||||
|
*/
|
||||||
|
updateMessage(postId: string, message: string): void {
|
||||||
|
const state = this.getStreamingState(postId);
|
||||||
|
if (!state) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.getSubject(postId).next({
|
||||||
|
...state,
|
||||||
|
message,
|
||||||
|
precontent: false,
|
||||||
|
generating: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End streaming for a post
|
||||||
|
* Preserves message but marks as done - POST_EDITED will call removePost to clear
|
||||||
|
*/
|
||||||
|
endStreaming(postId: string): void {
|
||||||
|
const state = this.getStreamingState(postId);
|
||||||
|
if (!state) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve message but mark as done - wait for POST_EDITED to clear via removePost
|
||||||
|
this.getSubject(postId).next({
|
||||||
|
...state,
|
||||||
|
generating: false,
|
||||||
|
precontent: false,
|
||||||
|
isReasoningLoading: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update reasoning summary
|
||||||
|
*/
|
||||||
|
updateReasoning(postId: string, reasoning: string, isLoading: boolean): void {
|
||||||
|
const state = this.getStreamingState(postId);
|
||||||
|
if (!state) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// During reasoning, explicitly set generating to false to prevent blinking cursor
|
||||||
|
const generating = isLoading ? false : state.generating;
|
||||||
|
const precontent = isLoading ? false : state.precontent;
|
||||||
|
|
||||||
|
this.getSubject(postId).next({
|
||||||
|
...state,
|
||||||
|
reasoning,
|
||||||
|
isReasoningLoading: isLoading,
|
||||||
|
showReasoning: true,
|
||||||
|
generating,
|
||||||
|
precontent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update tool calls
|
||||||
|
*/
|
||||||
|
updateToolCalls(postId: string, toolCallsJson: string): void {
|
||||||
|
let state = this.getStreamingState(postId);
|
||||||
|
|
||||||
|
// If no streaming state exists, create a minimal one for tool call updates
|
||||||
|
// This handles tool status updates that arrive after streaming ends but before POST_EDITED
|
||||||
|
if (!state) {
|
||||||
|
state = {
|
||||||
|
postId,
|
||||||
|
generating: false,
|
||||||
|
message: '',
|
||||||
|
precontent: false,
|
||||||
|
reasoning: '',
|
||||||
|
isReasoningLoading: false,
|
||||||
|
showReasoning: false,
|
||||||
|
toolCalls: [],
|
||||||
|
annotations: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const toolCalls = JSON.parse(toolCallsJson);
|
||||||
|
this.getSubject(postId).next({
|
||||||
|
...state,
|
||||||
|
toolCalls,
|
||||||
|
precontent: false,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logWarning('[StreamingPostStore.updateToolCalls]', error, {postId, toolCallsJson});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update annotations/citations
|
||||||
|
*/
|
||||||
|
updateAnnotations(postId: string, annotationsJson: string): void {
|
||||||
|
const state = this.getStreamingState(postId);
|
||||||
|
if (!state) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const annotations = JSON.parse(annotationsJson);
|
||||||
|
this.getSubject(postId).next({
|
||||||
|
...state,
|
||||||
|
annotations,
|
||||||
|
precontent: false,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logWarning('[StreamingPostStore.updateAnnotations]', error, {postId, annotationsJson});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a WebSocket message
|
||||||
|
*/
|
||||||
|
handleWebSocketMessage(data: PostUpdateWebsocketMessage): void {
|
||||||
|
const {post_id, next, control, reasoning, tool_call, annotations} = data;
|
||||||
|
|
||||||
|
if (!post_id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle control signals
|
||||||
|
if (control === CONTROL_SIGNALS.START) {
|
||||||
|
this.startStreaming(post_id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (control === CONTROL_SIGNALS.END || control === CONTROL_SIGNALS.CANCEL) {
|
||||||
|
this.endStreaming(post_id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle reasoning summary updates
|
||||||
|
if (control === CONTROL_SIGNALS.REASONING_SUMMARY && reasoning) {
|
||||||
|
// Replace entire reasoning with accumulated text from backend
|
||||||
|
this.updateReasoning(post_id, reasoning, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (control === CONTROL_SIGNALS.REASONING_SUMMARY_DONE) {
|
||||||
|
// Final reasoning text - mark as complete
|
||||||
|
const state = this.getStreamingState(post_id);
|
||||||
|
if (state && reasoning) {
|
||||||
|
this.updateReasoning(post_id, reasoning, false);
|
||||||
|
} else if (state) {
|
||||||
|
// Just mark as done if no new reasoning text
|
||||||
|
this.getSubject(post_id).next({
|
||||||
|
...state,
|
||||||
|
isReasoningLoading: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle tool call events
|
||||||
|
if (control === CONTROL_SIGNALS.TOOL_CALL && tool_call) {
|
||||||
|
this.updateToolCalls(post_id, tool_call);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle annotation events
|
||||||
|
if (control === CONTROL_SIGNALS.ANNOTATIONS && annotations) {
|
||||||
|
this.updateAnnotations(post_id, annotations);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle message updates
|
||||||
|
if (next) {
|
||||||
|
// Message comes as full accumulated text, not delta
|
||||||
|
this.updateMessage(post_id, next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current streaming state for a post (synchronous)
|
||||||
|
*/
|
||||||
|
getStreamingState(postId: string): StreamingState | undefined {
|
||||||
|
return this.streamingSubjects.get(postId)?.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observe streaming state for a post (reactive)
|
||||||
|
*/
|
||||||
|
observeStreamingState(postId: string): Observable<StreamingState | undefined> {
|
||||||
|
return this.getSubject(postId).asObservable();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a post is currently streaming (actively generating)
|
||||||
|
*/
|
||||||
|
isStreaming(postId: string): boolean {
|
||||||
|
const state = this.getStreamingState(postId);
|
||||||
|
return state?.generating ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove streaming state for a specific post
|
||||||
|
* Call this when a post is updated via POST_EDITED to ensure
|
||||||
|
* the component uses the persisted data from the database
|
||||||
|
*/
|
||||||
|
removePost(postId: string): void {
|
||||||
|
const subject = this.streamingSubjects.get(postId);
|
||||||
|
if (!subject) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signal end to subscribers, but keep subject alive for potential reuse
|
||||||
|
subject.next(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all streaming state (e.g., on logout)
|
||||||
|
*/
|
||||||
|
clear(): void {
|
||||||
|
// Complete all subjects before clearing
|
||||||
|
for (const subject of this.streamingSubjects.values()) {
|
||||||
|
subject.next(undefined);
|
||||||
|
subject.complete();
|
||||||
|
}
|
||||||
|
this.streamingSubjects.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
const streamingStore = new StreamingPostStore();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React hook to subscribe to streaming state for a post
|
||||||
|
*/
|
||||||
|
export function useStreamingState(postId: string): StreamingState | undefined {
|
||||||
|
const [state, setState] = useState<StreamingState | undefined>(
|
||||||
|
() => streamingStore.getStreamingState(postId),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const subscription = streamingStore.observeStreamingState(postId).subscribe(setState);
|
||||||
|
return () => subscription.unsubscribe();
|
||||||
|
}, [postId]);
|
||||||
|
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default streamingStore;
|
||||||
69
app/products/agents/types/index.ts
Normal file
69
app/products/agents/types/index.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool call status values
|
||||||
|
*/
|
||||||
|
export const ToolCallStatus = {
|
||||||
|
Pending: 0,
|
||||||
|
Accepted: 1,
|
||||||
|
Rejected: 2,
|
||||||
|
Error: 3,
|
||||||
|
Success: 4,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-redeclare -- TypeScript supports same-name type/value pairs as enum alternative
|
||||||
|
export type ToolCallStatus = typeof ToolCallStatus[keyof typeof ToolCallStatus];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tool call data structure
|
||||||
|
*/
|
||||||
|
export interface ToolCall {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
arguments: any;
|
||||||
|
result?: string;
|
||||||
|
status: ToolCallStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Citation/annotation data structure
|
||||||
|
*/
|
||||||
|
export interface Annotation {
|
||||||
|
type: string;
|
||||||
|
start_index: number;
|
||||||
|
end_index: number;
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
cited_text?: string;
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebSocket message data for agent post updates
|
||||||
|
*/
|
||||||
|
export interface PostUpdateWebsocketMessage {
|
||||||
|
post_id: string;
|
||||||
|
next?: string; // Full accumulated message text
|
||||||
|
control?: string; // Control signals: 'start', 'end', 'cancel', 'reasoning_summary', 'tool_call', 'annotations'
|
||||||
|
tool_call?: string; // JSON-encoded tool calls
|
||||||
|
reasoning?: string; // Reasoning summary text
|
||||||
|
annotations?: string; // JSON-encoded citations
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Streaming state for an active agent post
|
||||||
|
*/
|
||||||
|
export interface StreamingState {
|
||||||
|
postId: string;
|
||||||
|
generating: boolean;
|
||||||
|
message: string;
|
||||||
|
precontent: boolean; // True during 'start' before first content
|
||||||
|
reasoning: string; // Accumulated reasoning text
|
||||||
|
isReasoningLoading: boolean; // True while reasoning is being generated
|
||||||
|
showReasoning: boolean; // True if reasoning should be displayed
|
||||||
|
toolCalls: ToolCall[]; // Tool calls pending approval or processed
|
||||||
|
annotations: Annotation[]; // Citations/annotations for the post
|
||||||
|
}
|
||||||
|
|
||||||
134
app/products/agents/utils.test.ts
Normal file
134
app/products/agents/utils.test.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {AGENT_POST_TYPES} from '@agents/constants';
|
||||||
|
|
||||||
|
import TestHelper from '@test/test_helper';
|
||||||
|
|
||||||
|
import {isAgentPost, isPostRequester} from './utils';
|
||||||
|
|
||||||
|
describe('isAgentPost', () => {
|
||||||
|
describe('with Post objects', () => {
|
||||||
|
it('returns true when post type is custom_llmbot', () => {
|
||||||
|
const post = TestHelper.fakePost({type: AGENT_POST_TYPES.LLMBOT});
|
||||||
|
expect(isAgentPost(post)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true when post type is custom_llm_postback', () => {
|
||||||
|
const post = TestHelper.fakePost({type: AGENT_POST_TYPES.LLM_POSTBACK});
|
||||||
|
expect(isAgentPost(post)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for non-agent post types', () => {
|
||||||
|
const post = TestHelper.fakePost({type: ''});
|
||||||
|
expect(isAgentPost(post)).toBe(false);
|
||||||
|
|
||||||
|
const systemPost = TestHelper.fakePost({type: 'system_join_channel'});
|
||||||
|
expect(isAgentPost(systemPost)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with PostModel objects', () => {
|
||||||
|
it('returns true when post type is custom_llmbot', () => {
|
||||||
|
const postModel = TestHelper.fakePostModel({type: AGENT_POST_TYPES.LLMBOT});
|
||||||
|
expect(isAgentPost(postModel)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true when post type is custom_llm_postback', () => {
|
||||||
|
const postModel = TestHelper.fakePostModel({type: AGENT_POST_TYPES.LLM_POSTBACK});
|
||||||
|
expect(isAgentPost(postModel)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false for non-agent post types', () => {
|
||||||
|
const postModel = TestHelper.fakePostModel({type: ''});
|
||||||
|
expect(isAgentPost(postModel)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isPostRequester', () => {
|
||||||
|
const currentUserId = 'user123';
|
||||||
|
|
||||||
|
describe('with Post objects', () => {
|
||||||
|
it('returns true when llm_requester_user_id matches current user ID', () => {
|
||||||
|
const post = TestHelper.fakePost({
|
||||||
|
props: {
|
||||||
|
llm_requester_user_id: currentUserId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(isPostRequester(post, currentUserId)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when llm_requester_user_id does not match', () => {
|
||||||
|
const post = TestHelper.fakePost({
|
||||||
|
props: {
|
||||||
|
llm_requester_user_id: 'different_user',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(isPostRequester(post, currentUserId)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when props is undefined', () => {
|
||||||
|
const post = TestHelper.fakePost({
|
||||||
|
props: undefined,
|
||||||
|
});
|
||||||
|
expect(isPostRequester(post, currentUserId)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when props is empty object', () => {
|
||||||
|
const post = TestHelper.fakePost({
|
||||||
|
props: {},
|
||||||
|
});
|
||||||
|
expect(isPostRequester(post, currentUserId)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when llm_requester_user_id is missing', () => {
|
||||||
|
const post = TestHelper.fakePost({
|
||||||
|
props: {
|
||||||
|
some_other_prop: 'value',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(isPostRequester(post, currentUserId)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('with PostModel objects', () => {
|
||||||
|
it('returns true when llm_requester_user_id matches current user ID', () => {
|
||||||
|
const postModel = TestHelper.fakePostModel({
|
||||||
|
props: {
|
||||||
|
llm_requester_user_id: currentUserId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(isPostRequester(postModel, currentUserId)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when llm_requester_user_id does not match', () => {
|
||||||
|
const postModel = TestHelper.fakePostModel({
|
||||||
|
props: {
|
||||||
|
llm_requester_user_id: 'different_user',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(isPostRequester(postModel, currentUserId)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false when props is empty object', () => {
|
||||||
|
const postModel = TestHelper.fakePostModel({
|
||||||
|
props: {},
|
||||||
|
});
|
||||||
|
expect(isPostRequester(postModel, currentUserId)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('error handling', () => {
|
||||||
|
it('handles exceptions gracefully and returns false', () => {
|
||||||
|
// Create a post object that throws when accessing props
|
||||||
|
const faultyPost = {
|
||||||
|
get props() {
|
||||||
|
throw new Error('Access denied');
|
||||||
|
},
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
expect(isPostRequester(faultyPost, currentUserId)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
29
app/products/agents/utils.ts
Normal file
29
app/products/agents/utils.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {AGENT_POST_TYPES} from '@agents/constants';
|
||||||
|
|
||||||
|
import type PostModel from '@typings/database/models/servers/post';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a post is an agent post
|
||||||
|
*/
|
||||||
|
export function isAgentPost(post: PostModel | Post): boolean {
|
||||||
|
return post.type === AGENT_POST_TYPES.LLMBOT ||
|
||||||
|
post.type === AGENT_POST_TYPES.LLM_POSTBACK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the current user is the requester of an agent post
|
||||||
|
* @param post The agent post
|
||||||
|
* @param currentUserId The current user ID
|
||||||
|
* @returns true if current user is the requester
|
||||||
|
*/
|
||||||
|
export function isPostRequester(post: PostModel | Post, currentUserId: string): boolean {
|
||||||
|
try {
|
||||||
|
const props = post.props as Record<string, unknown>;
|
||||||
|
return props?.llm_requester_user_id === currentUserId;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -276,3 +276,8 @@ export function safeDecodeURIComponent(v: string) {
|
||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getUrlDomain(url: string): string {
|
||||||
|
const parsed = urlParse(url);
|
||||||
|
return parsed.hostname || url;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,22 @@
|
||||||
"account.logout_from": "Log out of {serverName}",
|
"account.logout_from": "Log out of {serverName}",
|
||||||
"account.settings": "Settings",
|
"account.settings": "Settings",
|
||||||
"account.your_profile": "Your Profile",
|
"account.your_profile": "Your Profile",
|
||||||
|
"agents.citations.title": "Sources ({count})",
|
||||||
|
"agents.controls.regenerate": "Regenerate",
|
||||||
|
"agents.controls.stop": "Stop Generating",
|
||||||
|
"agents.generating": "Generating response...",
|
||||||
|
"agents.reasoning.thinking": "Thinking",
|
||||||
|
"agents.regenerate.cancel": "Cancel",
|
||||||
|
"agents.regenerate.confirm": "Regenerate",
|
||||||
|
"agents.regenerate.confirm_message": "This will clear the current response and generate a new one. Continue?",
|
||||||
|
"agents.regenerate.confirm_title": "Regenerate Response",
|
||||||
|
"agents.tool_call.approve": "Accept",
|
||||||
|
"agents.tool_call.pending_decisions": "{count, plural, =0 {All tools decided} one {# tool needs a decision} other {# tools need decisions}}",
|
||||||
|
"agents.tool_call.processing": "Processing...",
|
||||||
|
"agents.tool_call.reject": "Reject",
|
||||||
|
"agents.tool_call.response": "Response",
|
||||||
|
"agents.tool_call.status.rejected": "Rejected",
|
||||||
|
"agents.tool_call.submitting": "Submitting...",
|
||||||
"alert.channel_deleted.description": "The channel {displayName} has been archived.",
|
"alert.channel_deleted.description": "The channel {displayName} has been archived.",
|
||||||
"alert.channel_deleted.title": "Archived channel",
|
"alert.channel_deleted.title": "Archived channel",
|
||||||
"alert.push_proxy_error.description": "Due to the configuration of this server, notifications cannot be received in the mobile app. Contact your system admin for more information.",
|
"alert.push_proxy_error.description": "Due to the configuration of this server, notifications cannot be received in the mobile app. Contact your system admin for more information.",
|
||||||
|
|
@ -1358,6 +1374,9 @@
|
||||||
"skintone_selector.tooltip.description": "You can now choose the skin tone you prefer to use for your emojis.",
|
"skintone_selector.tooltip.description": "You can now choose the skin tone you prefer to use for your emojis.",
|
||||||
"skintone_selector.tooltip.title": "Choose your default skin tone",
|
"skintone_selector.tooltip.title": "Choose your default skin tone",
|
||||||
"smobile.search.recent_title": "Recent searches in {teamName}",
|
"smobile.search.recent_title": "Recent searches in {teamName}",
|
||||||
|
"snack.bar.agent.regenerate.error": "Failed to regenerate response",
|
||||||
|
"snack.bar.agent.stop.error": "Failed to stop generation",
|
||||||
|
"snack.bar.agent.tool.approval.error": "Failed to submit tool approval",
|
||||||
"snack.bar.channel.members.added": "{numMembers, number} {numMembers, plural, one {member} other {members}} added",
|
"snack.bar.channel.members.added": "{numMembers, number} {numMembers, plural, one {member} other {members}} added",
|
||||||
"snack.bar.code.copied": "Code copied to clipboard",
|
"snack.bar.code.copied": "Code copied to clipboard",
|
||||||
"snack.bar.default": "Error",
|
"snack.bar.default": "Error",
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ module.exports = {
|
||||||
root: ['.'],
|
root: ['.'],
|
||||||
alias: {
|
alias: {
|
||||||
'@actions': './app/actions',
|
'@actions': './app/actions',
|
||||||
|
'@agents': './app/products/agents',
|
||||||
'@assets': './dist/assets/',
|
'@assets': './dist/assets/',
|
||||||
'@calls': './app/products/calls',
|
'@calls': './app/products/calls',
|
||||||
'@client': './app/client',
|
'@client': './app/client',
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@actions/*": ["app/actions/*"],
|
"@actions/*": ["app/actions/*"],
|
||||||
|
"@agents/*": ["app/products/agents/*"],
|
||||||
"@assets/*": ["dist/assets/*"],
|
"@assets/*": ["dist/assets/*"],
|
||||||
"@calls/*": ["app/products/calls/*"],
|
"@calls/*": ["app/products/calls/*"],
|
||||||
"@client/*": ["app/client/*"],
|
"@client/*": ["app/client/*"],
|
||||||
|
|
|
||||||
4
types/api/posts.d.ts
vendored
4
types/api/posts.d.ts
vendored
|
|
@ -28,7 +28,9 @@ type PostType =
|
||||||
| 'system_auto_responder'
|
| 'system_auto_responder'
|
||||||
| 'custom_calls'
|
| 'custom_calls'
|
||||||
| 'custom_calls_recording'
|
| 'custom_calls_recording'
|
||||||
| 'custom_run_update';
|
| 'custom_run_update'
|
||||||
|
| 'custom_llmbot'
|
||||||
|
| 'custom_llm_postback';
|
||||||
|
|
||||||
type PostEmbedType = 'image' | 'message_attachment' | 'opengraph' | 'permalink';
|
type PostEmbedType = 'image' | 'message_attachment' | 'opengraph' | 'permalink';
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue