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/app/src/main/res/raw/*
|
||||
.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.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {handleAgentPostUpdate} from '@agents/actions/websocket';
|
||||
|
||||
import * as bookmark from '@actions/local/channel_bookmark';
|
||||
import * as scheduledPost from '@actions/websocket/scheduled_post';
|
||||
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:
|
||||
handleCustomProfileAttributesFieldDeletedEvent(serverUrl, msg);
|
||||
break;
|
||||
|
||||
// Agents
|
||||
case WebsocketEvents.AGENTS_POST_UPDATE:
|
||||
handleAgentPostUpdate(msg);
|
||||
break;
|
||||
}
|
||||
handlePlaybookEvents(serverUrl, msg);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import streamingStore from '@agents/store/streaming_store';
|
||||
import {isAgentPost} from '@agents/utils';
|
||||
import {DeviceEventEmitter} from 'react-native';
|
||||
|
||||
import {storeMyChannelsForTeam, markChannelAsUnread, markChannelAsViewed, updateLastPostAt} from '@actions/local/channel';
|
||||
|
|
@ -267,7 +269,11 @@ export async function handlePostEdited(serverUrl: string, msg: WebSocketMessage)
|
|||
});
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import ClientAgents, {type ClientAgentsMix} from '@agents/client/rest';
|
||||
|
||||
import ClientCalls, {type ClientCallsMix} from '@calls/client/rest';
|
||||
import ClientPlugins, {type ClientPluginsMix} from '@client/rest/plugins';
|
||||
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';
|
||||
|
||||
interface Client extends ClientBase,
|
||||
ClientAgentsMix,
|
||||
ClientAppsMix,
|
||||
ClientCategoriesMix,
|
||||
ClientChannelsMix,
|
||||
|
|
@ -57,6 +60,7 @@ interface Client extends ClientBase,
|
|||
}
|
||||
|
||||
class Client extends mix(ClientBase).with(
|
||||
ClientAgents,
|
||||
ClientApps,
|
||||
ClientCategories,
|
||||
ClientChannels,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// 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 {useIntl} from 'react-intl';
|
||||
import {Keyboard, Platform, type StyleProp, View, type ViewStyle, TouchableHighlight} from 'react-native';
|
||||
|
|
@ -157,6 +159,7 @@ const Post = ({
|
|||
const isFailed = isPostFailed(post);
|
||||
const isSystemPost = isSystemMessage(post);
|
||||
const isCallsPost = isCallsCustomMessage(post);
|
||||
const isAgentPostType = isAgentPost(post);
|
||||
const hasBeenDeleted = (post.deleteAt !== 0);
|
||||
const isWebHook = isFromWebhook(post);
|
||||
const hasSameRoot = useMemo(() => {
|
||||
|
|
@ -251,6 +254,8 @@ const Post = ({
|
|||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -264,6 +269,8 @@ const Post = ({
|
|||
|
||||
PerformanceMetricsManager.finishLoad(location === 'Thread' ? 'THREAD' : 'CHANNEL', 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;
|
||||
|
|
@ -356,6 +363,14 @@ const Post = ({
|
|||
joiningChannelId={null}
|
||||
/>
|
||||
);
|
||||
} else if (isAgentPostType && !hasBeenDeleted) {
|
||||
body = (
|
||||
<AgentPost
|
||||
post={post}
|
||||
currentUserId={currentUser?.id}
|
||||
location={location}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
body = (
|
||||
<Body
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ export const PostTypes = {
|
|||
SYSTEM_AUTO_RESPONDER: 'system_auto_responder',
|
||||
CUSTOM_CALLS: 'custom_calls',
|
||||
CUSTOM_CALLS_RECORDING: 'custom_calls_recording',
|
||||
CUSTOM_LLMBOT: 'custom_llmbot',
|
||||
CUSTOM_LLM_POSTBACK: 'custom_llm_postback',
|
||||
} as const;
|
||||
|
||||
export const PostPriorityColors = {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ import keyMirror from '@utils/key_mirror';
|
|||
|
||||
export const SNACK_BAR_TYPE = keyMirror({
|
||||
ADD_CHANNEL_MEMBERS: null,
|
||||
AGENT_STOP_ERROR: null,
|
||||
AGENT_REGENERATE_ERROR: null,
|
||||
AGENT_TOOL_APPROVAL_ERROR: null,
|
||||
CODE_COPIED: null,
|
||||
FAVORITE_CHANNEL: null,
|
||||
FOLLOW_THREAD: null,
|
||||
|
|
@ -46,6 +49,18 @@ const messages = defineMessages({
|
|||
id: 'snack.bar.channel.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: {
|
||||
id: 'snack.bar.code.copied',
|
||||
defaultMessage: 'Code copied to clipboard',
|
||||
|
|
@ -110,6 +125,24 @@ export const SNACK_BAR_CONFIG: Record<string, SnackBarConfig> = {
|
|||
iconName: 'check',
|
||||
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: {
|
||||
message: messages.CODE_COPIED,
|
||||
iconName: 'content-copy',
|
||||
|
|
|
|||
|
|
@ -105,6 +105,9 @@ const WebsocketEvents = {
|
|||
CUSTOM_PROFILE_ATTRIBUTES_FIELD_UPDATED: 'custom_profile_attributes_field_updated',
|
||||
CUSTOM_PROFILE_ATTRIBUTES_FIELD_CREATED: 'custom_profile_attributes_field_created',
|
||||
CUSTOM_PROFILE_ATTRIBUTES_FIELD_DELETED: 'custom_profile_attributes_field_deleted',
|
||||
|
||||
// Agents
|
||||
AGENTS_POST_UPDATE: 'custom_mattermost-ai_postupdate',
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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.settings": "Settings",
|
||||
"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.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.",
|
||||
|
|
@ -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.title": "Choose your default skin tone",
|
||||
"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.code.copied": "Code copied to clipboard",
|
||||
"snack.bar.default": "Error",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ module.exports = {
|
|||
root: ['.'],
|
||||
alias: {
|
||||
'@actions': './app/actions',
|
||||
'@agents': './app/products/agents',
|
||||
'@assets': './dist/assets/',
|
||||
'@calls': './app/products/calls',
|
||||
'@client': './app/client',
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@
|
|||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@actions/*": ["app/actions/*"],
|
||||
"@agents/*": ["app/products/agents/*"],
|
||||
"@assets/*": ["dist/assets/*"],
|
||||
"@calls/*": ["app/products/calls/*"],
|
||||
"@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'
|
||||
| 'custom_calls'
|
||||
| 'custom_calls_recording'
|
||||
| 'custom_run_update';
|
||||
| 'custom_run_update'
|
||||
| 'custom_llmbot'
|
||||
| 'custom_llm_postback';
|
||||
|
||||
type PostEmbedType = 'image' | 'message_attachment' | 'opengraph' | 'permalink';
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue