mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-20 04:17:57 -05:00
Compare commits
3 Commits
main
...
feat/super
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
526b7a64f6 | ||
|
|
9da689bc8e | ||
|
|
e1bea05de0 |
@@ -1,35 +0,0 @@
|
|||||||
---
|
|
||||||
paths:
|
|
||||||
- "apps/sim/components/emcn/**"
|
|
||||||
---
|
|
||||||
|
|
||||||
# EMCN Components
|
|
||||||
|
|
||||||
Import from `@/components/emcn`, never from subpaths (except CSS files).
|
|
||||||
|
|
||||||
## CVA vs Direct Styles
|
|
||||||
|
|
||||||
**Use CVA when:** 2+ variants (primary/secondary, sm/md/lg)
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
const buttonVariants = cva('base-classes', {
|
|
||||||
variants: { variant: { default: '...', primary: '...' } }
|
|
||||||
})
|
|
||||||
export { Button, buttonVariants }
|
|
||||||
```
|
|
||||||
|
|
||||||
**Use direct className when:** Single consistent style, no variations
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
function Label({ className, ...props }) {
|
|
||||||
return <Primitive className={cn('style-classes', className)} {...props} />
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Rules
|
|
||||||
|
|
||||||
- Use Radix UI primitives for accessibility
|
|
||||||
- Export component and variants (if using CVA)
|
|
||||||
- TSDoc with usage examples
|
|
||||||
- Consistent tokens: `font-medium`, `text-[12px]`, `rounded-[4px]`
|
|
||||||
- `transition-colors` for hover states
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# Global Standards
|
|
||||||
|
|
||||||
## Logging
|
|
||||||
Import `createLogger` from `sim/logger`. Use `logger.info`, `logger.warn`, `logger.error` instead of `console.log`.
|
|
||||||
|
|
||||||
## Comments
|
|
||||||
Use TSDoc for documentation. No `====` separators. No non-TSDoc comments.
|
|
||||||
|
|
||||||
## Styling
|
|
||||||
Never update global styles. Keep all styling local to components.
|
|
||||||
|
|
||||||
## Package Manager
|
|
||||||
Use `bun` and `bunx`, not `npm` and `npx`.
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
---
|
|
||||||
paths:
|
|
||||||
- "apps/sim/**"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Sim App Architecture
|
|
||||||
|
|
||||||
## Core Principles
|
|
||||||
1. **Single Responsibility**: Each component, hook, store has one clear purpose
|
|
||||||
2. **Composition Over Complexity**: Break down complex logic into smaller pieces
|
|
||||||
3. **Type Safety First**: TypeScript interfaces for all props, state, return types
|
|
||||||
4. **Predictable State**: Zustand for global state, useState for UI-only concerns
|
|
||||||
|
|
||||||
## Root-Level Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
apps/sim/
|
|
||||||
├── app/ # Next.js app router (pages, API routes)
|
|
||||||
├── blocks/ # Block definitions and registry
|
|
||||||
├── components/ # Shared UI (emcn/, ui/)
|
|
||||||
├── executor/ # Workflow execution engine
|
|
||||||
├── hooks/ # Shared hooks (queries/, selectors/)
|
|
||||||
├── lib/ # App-wide utilities
|
|
||||||
├── providers/ # LLM provider integrations
|
|
||||||
├── stores/ # Zustand stores
|
|
||||||
├── tools/ # Tool definitions
|
|
||||||
└── triggers/ # Trigger definitions
|
|
||||||
```
|
|
||||||
|
|
||||||
## Feature Organization
|
|
||||||
|
|
||||||
Features live under `app/workspace/[workspaceId]/`:
|
|
||||||
|
|
||||||
```
|
|
||||||
feature/
|
|
||||||
├── components/ # Feature components
|
|
||||||
├── hooks/ # Feature-scoped hooks
|
|
||||||
├── utils/ # Feature-scoped utilities (2+ consumers)
|
|
||||||
├── feature.tsx # Main component
|
|
||||||
└── page.tsx # Next.js page entry
|
|
||||||
```
|
|
||||||
|
|
||||||
## Naming Conventions
|
|
||||||
- **Components**: PascalCase (`WorkflowList`)
|
|
||||||
- **Hooks**: `use` prefix (`useWorkflowOperations`)
|
|
||||||
- **Files**: kebab-case (`workflow-list.tsx`)
|
|
||||||
- **Stores**: `stores/feature/store.ts`
|
|
||||||
- **Constants**: SCREAMING_SNAKE_CASE
|
|
||||||
- **Interfaces**: PascalCase with suffix (`WorkflowListProps`)
|
|
||||||
|
|
||||||
## Utils Rules
|
|
||||||
|
|
||||||
- **Never create `utils.ts` for single consumer** - inline it
|
|
||||||
- **Create `utils.ts` when** 2+ files need the same helper
|
|
||||||
- **Check existing sources** before duplicating (`lib/` has many utilities)
|
|
||||||
- **Location**: `lib/` (app-wide) → `feature/utils/` (feature-scoped) → inline (single-use)
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
---
|
|
||||||
paths:
|
|
||||||
- "apps/sim/**/*.tsx"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Component Patterns
|
|
||||||
|
|
||||||
## Structure Order
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
'use client' // Only if using hooks
|
|
||||||
|
|
||||||
// Imports (external → internal)
|
|
||||||
// Constants at module level
|
|
||||||
const CONFIG = { SPACING: 8 } as const
|
|
||||||
|
|
||||||
// Props interface
|
|
||||||
interface ComponentProps {
|
|
||||||
requiredProp: string
|
|
||||||
optionalProp?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Component({ requiredProp, optionalProp = false }: ComponentProps) {
|
|
||||||
// a. Refs
|
|
||||||
// b. External hooks (useParams, useRouter)
|
|
||||||
// c. Store hooks
|
|
||||||
// d. Custom hooks
|
|
||||||
// e. Local state
|
|
||||||
// f. useMemo
|
|
||||||
// g. useCallback
|
|
||||||
// h. useEffect
|
|
||||||
// i. Return JSX
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Rules
|
|
||||||
|
|
||||||
1. `'use client'` only when using React hooks
|
|
||||||
2. Always define props interface
|
|
||||||
3. Extract constants with `as const`
|
|
||||||
4. Semantic HTML (`aside`, `nav`, `article`)
|
|
||||||
5. Optional chain callbacks: `onAction?.(id)`
|
|
||||||
|
|
||||||
## Component Extraction
|
|
||||||
|
|
||||||
**Extract when:** 50+ lines, used in 2+ files, or has own state/logic
|
|
||||||
|
|
||||||
**Keep inline when:** < 10 lines, single use, purely presentational
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
---
|
|
||||||
paths:
|
|
||||||
- "apps/sim/**/use-*.ts"
|
|
||||||
- "apps/sim/**/hooks/**/*.ts"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Hook Patterns
|
|
||||||
|
|
||||||
## Structure
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface UseFeatureProps {
|
|
||||||
id: string
|
|
||||||
onSuccess?: (result: Result) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useFeature({ id, onSuccess }: UseFeatureProps) {
|
|
||||||
// 1. Refs for stable dependencies
|
|
||||||
const idRef = useRef(id)
|
|
||||||
const onSuccessRef = useRef(onSuccess)
|
|
||||||
|
|
||||||
// 2. State
|
|
||||||
const [data, setData] = useState<Data | null>(null)
|
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
|
||||||
|
|
||||||
// 3. Sync refs
|
|
||||||
useEffect(() => {
|
|
||||||
idRef.current = id
|
|
||||||
onSuccessRef.current = onSuccess
|
|
||||||
}, [id, onSuccess])
|
|
||||||
|
|
||||||
// 4. Operations (useCallback with empty deps when using refs)
|
|
||||||
const fetchData = useCallback(async () => {
|
|
||||||
setIsLoading(true)
|
|
||||||
try {
|
|
||||||
const result = await fetch(`/api/${idRef.current}`).then(r => r.json())
|
|
||||||
setData(result)
|
|
||||||
onSuccessRef.current?.(result)
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return { data, isLoading, fetchData }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Rules
|
|
||||||
|
|
||||||
1. Single responsibility per hook
|
|
||||||
2. Props interface required
|
|
||||||
3. Refs for stable callback dependencies
|
|
||||||
4. Wrap returned functions in useCallback
|
|
||||||
5. Always try/catch async operations
|
|
||||||
6. Track loading/error states
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
---
|
|
||||||
paths:
|
|
||||||
- "apps/sim/**/*.ts"
|
|
||||||
- "apps/sim/**/*.tsx"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Import Patterns
|
|
||||||
|
|
||||||
## Absolute Imports
|
|
||||||
|
|
||||||
**Always use absolute imports.** Never use relative imports.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ✓ Good
|
|
||||||
import { useWorkflowStore } from '@/stores/workflows/store'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
|
|
||||||
// ✗ Bad
|
|
||||||
import { useWorkflowStore } from '../../../stores/workflows/store'
|
|
||||||
```
|
|
||||||
|
|
||||||
## Barrel Exports
|
|
||||||
|
|
||||||
Use barrel exports (`index.ts`) when a folder has 3+ exports. Import from barrel, not individual files.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ✓ Good
|
|
||||||
import { Dashboard, Sidebar } from '@/app/workspace/[workspaceId]/logs/components'
|
|
||||||
|
|
||||||
// ✗ Bad
|
|
||||||
import { Dashboard } from '@/app/workspace/[workspaceId]/logs/components/dashboard/dashboard'
|
|
||||||
```
|
|
||||||
|
|
||||||
## No Re-exports
|
|
||||||
|
|
||||||
Do not re-export from non-barrel files. Import directly from the source.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ✓ Good - import from where it's declared
|
|
||||||
import { CORE_TRIGGER_TYPES } from '@/stores/logs/filters/types'
|
|
||||||
|
|
||||||
// ✗ Bad - re-exporting in utils.ts then importing from there
|
|
||||||
import { CORE_TRIGGER_TYPES } from '@/app/workspace/.../utils'
|
|
||||||
```
|
|
||||||
|
|
||||||
## Import Order
|
|
||||||
|
|
||||||
1. React/core libraries
|
|
||||||
2. External libraries
|
|
||||||
3. UI components (`@/components/emcn`, `@/components/ui`)
|
|
||||||
4. Utilities (`@/lib/...`)
|
|
||||||
5. Stores (`@/stores/...`)
|
|
||||||
6. Feature imports
|
|
||||||
7. CSS imports
|
|
||||||
|
|
||||||
## Type Imports
|
|
||||||
|
|
||||||
Use `type` keyword for type-only imports:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import type { WorkflowLog } from '@/stores/logs/types'
|
|
||||||
```
|
|
||||||
@@ -1,209 +0,0 @@
|
|||||||
---
|
|
||||||
paths:
|
|
||||||
- "apps/sim/tools/**"
|
|
||||||
- "apps/sim/blocks/**"
|
|
||||||
- "apps/sim/triggers/**"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Adding Integrations
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Adding a new integration typically requires:
|
|
||||||
1. **Tools** - API operations (`tools/{service}/`)
|
|
||||||
2. **Block** - UI component (`blocks/blocks/{service}.ts`)
|
|
||||||
3. **Icon** - SVG icon (`components/icons.tsx`)
|
|
||||||
4. **Trigger** (optional) - Webhooks/polling (`triggers/{service}/`)
|
|
||||||
|
|
||||||
Always look up the service's API docs first.
|
|
||||||
|
|
||||||
## 1. Tools (`tools/{service}/`)
|
|
||||||
|
|
||||||
```
|
|
||||||
tools/{service}/
|
|
||||||
├── index.ts # Export all tools
|
|
||||||
├── types.ts # Params/response types
|
|
||||||
├── {action}.ts # Individual tool (e.g., send_message.ts)
|
|
||||||
└── ...
|
|
||||||
```
|
|
||||||
|
|
||||||
**Tool file structure:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// tools/{service}/{action}.ts
|
|
||||||
import type { {Service}Params, {Service}Response } from '@/tools/{service}/types'
|
|
||||||
import type { ToolConfig } from '@/tools/types'
|
|
||||||
|
|
||||||
export const {service}{Action}Tool: ToolConfig<{Service}Params, {Service}Response> = {
|
|
||||||
id: '{service}_{action}',
|
|
||||||
name: '{Service} {Action}',
|
|
||||||
description: 'What this tool does',
|
|
||||||
version: '1.0.0',
|
|
||||||
oauth: { required: true, provider: '{service}' }, // if OAuth
|
|
||||||
params: { /* param definitions */ },
|
|
||||||
request: {
|
|
||||||
url: '/api/tools/{service}/{action}',
|
|
||||||
method: 'POST',
|
|
||||||
headers: () => ({ 'Content-Type': 'application/json' }),
|
|
||||||
body: (params) => ({ ...params }),
|
|
||||||
},
|
|
||||||
transformResponse: async (response) => {
|
|
||||||
const data = await response.json()
|
|
||||||
if (!data.success) throw new Error(data.error)
|
|
||||||
return { success: true, output: data.output }
|
|
||||||
},
|
|
||||||
outputs: { /* output definitions */ },
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Register in `tools/registry.ts`:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { {service}{Action}Tool } from '@/tools/{service}'
|
|
||||||
// Add to registry object
|
|
||||||
{service}_{action}: {service}{Action}Tool,
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2. Block (`blocks/blocks/{service}.ts`)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { {Service}Icon } from '@/components/icons'
|
|
||||||
import type { BlockConfig } from '@/blocks/types'
|
|
||||||
import type { {Service}Response } from '@/tools/{service}/types'
|
|
||||||
|
|
||||||
export const {Service}Block: BlockConfig<{Service}Response> = {
|
|
||||||
type: '{service}',
|
|
||||||
name: '{Service}',
|
|
||||||
description: 'Short description',
|
|
||||||
longDescription: 'Detailed description',
|
|
||||||
category: 'tools',
|
|
||||||
bgColor: '#hexcolor',
|
|
||||||
icon: {Service}Icon,
|
|
||||||
subBlocks: [ /* see SubBlock Properties below */ ],
|
|
||||||
tools: {
|
|
||||||
access: ['{service}_{action}', ...],
|
|
||||||
config: {
|
|
||||||
tool: (params) => `{service}_${params.operation}`,
|
|
||||||
params: (params) => ({ ...params }),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
inputs: { /* input definitions */ },
|
|
||||||
outputs: { /* output definitions */ },
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### SubBlock Properties
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
id: 'fieldName', // Unique identifier
|
|
||||||
title: 'Field Label', // UI label
|
|
||||||
type: 'short-input', // See SubBlock Types below
|
|
||||||
placeholder: 'Hint text',
|
|
||||||
required: true, // See Required below
|
|
||||||
condition: { ... }, // See Condition below
|
|
||||||
dependsOn: ['otherField'], // See DependsOn below
|
|
||||||
mode: 'basic', // 'basic' | 'advanced' | 'both' | 'trigger'
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**SubBlock Types:** `short-input`, `long-input`, `dropdown`, `code`, `switch`, `slider`, `oauth-input`, `channel-selector`, `user-selector`, `file-upload`, etc.
|
|
||||||
|
|
||||||
### `condition` - Show/hide based on another field
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Show when operation === 'send'
|
|
||||||
condition: { field: 'operation', value: 'send' }
|
|
||||||
|
|
||||||
// Show when operation is 'send' OR 'read'
|
|
||||||
condition: { field: 'operation', value: ['send', 'read'] }
|
|
||||||
|
|
||||||
// Show when operation !== 'send'
|
|
||||||
condition: { field: 'operation', value: 'send', not: true }
|
|
||||||
|
|
||||||
// Complex: NOT in list AND another condition
|
|
||||||
condition: {
|
|
||||||
field: 'operation',
|
|
||||||
value: ['list_channels', 'list_users'],
|
|
||||||
not: true,
|
|
||||||
and: { field: 'destinationType', value: 'dm', not: true }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `required` - Field validation
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Always required
|
|
||||||
required: true
|
|
||||||
|
|
||||||
// Conditionally required (same syntax as condition)
|
|
||||||
required: { field: 'operation', value: 'send' }
|
|
||||||
```
|
|
||||||
|
|
||||||
### `dependsOn` - Clear field when dependencies change
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Clear when credential changes
|
|
||||||
dependsOn: ['credential']
|
|
||||||
|
|
||||||
// Clear when authMethod changes AND (credential OR botToken) changes
|
|
||||||
dependsOn: { all: ['authMethod'], any: ['credential', 'botToken'] }
|
|
||||||
```
|
|
||||||
|
|
||||||
### `mode` - When to show field
|
|
||||||
|
|
||||||
- `'basic'` - Only in basic mode (default UI)
|
|
||||||
- `'advanced'` - Only in advanced mode (manual input)
|
|
||||||
- `'both'` - Show in both modes (default)
|
|
||||||
- `'trigger'` - Only when block is used as trigger
|
|
||||||
|
|
||||||
**Register in `blocks/registry.ts`:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { {Service}Block } from '@/blocks/blocks/{service}'
|
|
||||||
// Add to registry object (alphabetically)
|
|
||||||
{service}: {Service}Block,
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. Icon (`components/icons.tsx`)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export function {Service}Icon(props: SVGProps<SVGSVGElement>) {
|
|
||||||
return (
|
|
||||||
<svg {...props} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
{/* SVG path from service's brand assets */}
|
|
||||||
</svg>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. Trigger (`triggers/{service}/`) - Optional
|
|
||||||
|
|
||||||
```
|
|
||||||
triggers/{service}/
|
|
||||||
├── index.ts # Export all triggers
|
|
||||||
├── webhook.ts # Webhook handler
|
|
||||||
├── utils.ts # Shared utilities
|
|
||||||
└── {event}.ts # Specific event handlers
|
|
||||||
```
|
|
||||||
|
|
||||||
**Register in `triggers/registry.ts`:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { {service}WebhookTrigger } from '@/triggers/{service}'
|
|
||||||
// Add to TRIGGER_REGISTRY
|
|
||||||
{service}_webhook: {service}WebhookTrigger,
|
|
||||||
```
|
|
||||||
|
|
||||||
## Checklist
|
|
||||||
|
|
||||||
- [ ] Look up API docs for the service
|
|
||||||
- [ ] Create `tools/{service}/types.ts` with proper types
|
|
||||||
- [ ] Create tool files for each operation
|
|
||||||
- [ ] Create `tools/{service}/index.ts` barrel export
|
|
||||||
- [ ] Register tools in `tools/registry.ts`
|
|
||||||
- [ ] Add icon to `components/icons.tsx`
|
|
||||||
- [ ] Create block in `blocks/blocks/{service}.ts`
|
|
||||||
- [ ] Register block in `blocks/registry.ts`
|
|
||||||
- [ ] (Optional) Create triggers in `triggers/{service}/`
|
|
||||||
- [ ] (Optional) Register triggers in `triggers/registry.ts`
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
---
|
|
||||||
paths:
|
|
||||||
- "apps/sim/hooks/queries/**/*.ts"
|
|
||||||
---
|
|
||||||
|
|
||||||
# React Query Patterns
|
|
||||||
|
|
||||||
All React Query hooks live in `hooks/queries/`.
|
|
||||||
|
|
||||||
## Query Key Factory
|
|
||||||
|
|
||||||
Every query file defines a keys factory:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export const entityKeys = {
|
|
||||||
all: ['entity'] as const,
|
|
||||||
list: (workspaceId?: string) => [...entityKeys.all, 'list', workspaceId ?? ''] as const,
|
|
||||||
detail: (id?: string) => [...entityKeys.all, 'detail', id ?? ''] as const,
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// 1. Query keys factory
|
|
||||||
// 2. Types (if needed)
|
|
||||||
// 3. Private fetch functions
|
|
||||||
// 4. Exported hooks
|
|
||||||
```
|
|
||||||
|
|
||||||
## Query Hook
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export function useEntityList(workspaceId?: string, options?: { enabled?: boolean }) {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: entityKeys.list(workspaceId),
|
|
||||||
queryFn: () => fetchEntities(workspaceId as string),
|
|
||||||
enabled: Boolean(workspaceId) && (options?.enabled ?? true),
|
|
||||||
staleTime: 60 * 1000,
|
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Mutation Hook
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export function useCreateEntity() {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: async (variables) => { /* fetch POST */ },
|
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: entityKeys.all }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Optimistic Updates
|
|
||||||
|
|
||||||
For optimistic mutations syncing with Zustand, use `createOptimisticMutationHandlers` from `@/hooks/queries/utils/optimistic-mutation`.
|
|
||||||
|
|
||||||
## Naming
|
|
||||||
|
|
||||||
- **Keys**: `entityKeys`
|
|
||||||
- **Query hooks**: `useEntity`, `useEntityList`
|
|
||||||
- **Mutation hooks**: `useCreateEntity`, `useUpdateEntity`
|
|
||||||
- **Fetch functions**: `fetchEntity` (private)
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
---
|
|
||||||
paths:
|
|
||||||
- "apps/sim/**/store.ts"
|
|
||||||
- "apps/sim/**/stores/**/*.ts"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Zustand Store Patterns
|
|
||||||
|
|
||||||
Stores live in `stores/`. Complex stores split into `store.ts` + `types.ts`.
|
|
||||||
|
|
||||||
## Basic Store
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { create } from 'zustand'
|
|
||||||
import { devtools } from 'zustand/middleware'
|
|
||||||
import type { FeatureState } from '@/stores/feature/types'
|
|
||||||
|
|
||||||
const initialState = { items: [] as Item[], activeId: null as string | null }
|
|
||||||
|
|
||||||
export const useFeatureStore = create<FeatureState>()(
|
|
||||||
devtools(
|
|
||||||
(set, get) => ({
|
|
||||||
...initialState,
|
|
||||||
setItems: (items) => set({ items }),
|
|
||||||
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
|
|
||||||
reset: () => set(initialState),
|
|
||||||
}),
|
|
||||||
{ name: 'feature-store' }
|
|
||||||
)
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Persisted Store
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { create } from 'zustand'
|
|
||||||
import { persist } from 'zustand/middleware'
|
|
||||||
|
|
||||||
export const useFeatureStore = create<FeatureState>()(
|
|
||||||
persist(
|
|
||||||
(set) => ({
|
|
||||||
width: 300,
|
|
||||||
setWidth: (width) => set({ width }),
|
|
||||||
_hasHydrated: false,
|
|
||||||
setHasHydrated: (v) => set({ _hasHydrated: v }),
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
name: 'feature-state',
|
|
||||||
partialize: (state) => ({ width: state.width }),
|
|
||||||
onRehydrateStorage: () => (state) => state?.setHasHydrated(true),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Rules
|
|
||||||
|
|
||||||
1. Use `devtools` middleware (named stores)
|
|
||||||
2. Use `persist` only when data should survive reload
|
|
||||||
3. `partialize` to persist only necessary state
|
|
||||||
4. `_hasHydrated` pattern for persisted stores needing hydration tracking
|
|
||||||
5. Immutable updates only
|
|
||||||
6. `set((state) => ...)` when depending on previous state
|
|
||||||
7. Provide `reset()` action
|
|
||||||
|
|
||||||
## Outside React
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const items = useFeatureStore.getState().items
|
|
||||||
useFeatureStore.setState({ items: newItems })
|
|
||||||
```
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
---
|
|
||||||
paths:
|
|
||||||
- "apps/sim/**/*.tsx"
|
|
||||||
- "apps/sim/**/*.css"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Styling Rules
|
|
||||||
|
|
||||||
## Tailwind
|
|
||||||
|
|
||||||
1. **No inline styles** - Use Tailwind classes
|
|
||||||
2. **No duplicate dark classes** - Skip `dark:` when value matches light mode
|
|
||||||
3. **Exact values** - `text-[14px]`, `h-[26px]`
|
|
||||||
4. **Transitions** - `transition-colors` for interactive states
|
|
||||||
|
|
||||||
## Conditional Classes
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
<div className={cn(
|
|
||||||
'base-classes',
|
|
||||||
isActive && 'active-classes',
|
|
||||||
disabled ? 'opacity-60' : 'hover:bg-accent'
|
|
||||||
)} />
|
|
||||||
```
|
|
||||||
|
|
||||||
## CSS Variables
|
|
||||||
|
|
||||||
For dynamic values (widths, heights) synced with stores:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// In store
|
|
||||||
setWidth: (width) => {
|
|
||||||
set({ width })
|
|
||||||
document.documentElement.style.setProperty('--sidebar-width', `${width}px`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// In component
|
|
||||||
<aside style={{ width: 'var(--sidebar-width)' }} />
|
|
||||||
```
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
---
|
|
||||||
paths:
|
|
||||||
- "apps/sim/**/*.test.ts"
|
|
||||||
- "apps/sim/**/*.test.tsx"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Testing Patterns
|
|
||||||
|
|
||||||
Use Vitest. Test files: `feature.ts` → `feature.test.ts`
|
|
||||||
|
|
||||||
## Structure
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
/**
|
|
||||||
* @vitest-environment node
|
|
||||||
*/
|
|
||||||
import { databaseMock, loggerMock } from '@sim/testing'
|
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
|
||||||
|
|
||||||
vi.mock('@sim/db', () => databaseMock)
|
|
||||||
vi.mock('@sim/logger', () => loggerMock)
|
|
||||||
|
|
||||||
import { myFunction } from '@/lib/feature'
|
|
||||||
|
|
||||||
describe('myFunction', () => {
|
|
||||||
beforeEach(() => vi.clearAllMocks())
|
|
||||||
it.concurrent('isolated tests run in parallel', () => { ... })
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
## @sim/testing Package
|
|
||||||
|
|
||||||
Always prefer over local mocks.
|
|
||||||
|
|
||||||
| Category | Utilities |
|
|
||||||
|----------|-----------|
|
|
||||||
| **Mocks** | `loggerMock`, `databaseMock`, `setupGlobalFetchMock()` |
|
|
||||||
| **Factories** | `createSession()`, `createWorkflowRecord()`, `createBlock()`, `createExecutorContext()` |
|
|
||||||
| **Builders** | `WorkflowBuilder`, `ExecutionContextBuilder` |
|
|
||||||
| **Assertions** | `expectWorkflowAccessGranted()`, `expectBlockExecuted()` |
|
|
||||||
|
|
||||||
## Rules
|
|
||||||
|
|
||||||
1. `@vitest-environment node` directive at file top
|
|
||||||
2. `vi.mock()` calls before importing mocked modules
|
|
||||||
3. `@sim/testing` utilities over local mocks
|
|
||||||
4. `it.concurrent` for isolated tests (no shared mutable state)
|
|
||||||
5. `beforeEach(() => vi.clearAllMocks())` to reset state
|
|
||||||
|
|
||||||
## Hoisted Mocks
|
|
||||||
|
|
||||||
For mutable mock references:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const mockFn = vi.hoisted(() => vi.fn())
|
|
||||||
vi.mock('@/lib/module', () => ({ myFunction: mockFn }))
|
|
||||||
mockFn.mockResolvedValue({ data: 'test' })
|
|
||||||
```
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
---
|
|
||||||
paths:
|
|
||||||
- "apps/sim/**/*.ts"
|
|
||||||
- "apps/sim/**/*.tsx"
|
|
||||||
---
|
|
||||||
|
|
||||||
# TypeScript Rules
|
|
||||||
|
|
||||||
1. **No `any`** - Use proper types or `unknown` with type guards
|
|
||||||
2. **Props interface** - Always define for components
|
|
||||||
3. **Const assertions** - `as const` for constant objects/arrays
|
|
||||||
4. **Ref types** - Explicit: `useRef<HTMLDivElement>(null)`
|
|
||||||
5. **Type imports** - `import type { X }` for type-only imports
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ✗ Bad
|
|
||||||
const handleClick = (e: any) => {}
|
|
||||||
|
|
||||||
// ✓ Good
|
|
||||||
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {}
|
|
||||||
```
|
|
||||||
@@ -8,7 +8,7 @@ alwaysApply: true
|
|||||||
You are a professional software engineer. All code must follow best practices: accurate, readable, clean, and efficient.
|
You are a professional software engineer. All code must follow best practices: accurate, readable, clean, and efficient.
|
||||||
|
|
||||||
## Logging
|
## Logging
|
||||||
Import `createLogger` from `@sim/logger`. Use `logger.info`, `logger.warn`, `logger.error` instead of `console.log`.
|
Import `createLogger` from `sim/logger`. Use `logger.info`, `logger.warn`, `logger.error` instead of `console.log`.
|
||||||
|
|
||||||
## Comments
|
## Comments
|
||||||
Use TSDoc for documentation. No `====` separators. No non-TSDoc comments.
|
Use TSDoc for documentation. No `====` separators. No non-TSDoc comments.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { eq } from 'drizzle-orm'
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
|
|
||||||
const logger = createLogger('SSOProvidersRoute')
|
const logger = createLogger('SSO-Providers')
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { hasSSOAccess } from '@/lib/billing'
|
|||||||
import { env } from '@/lib/core/config/env'
|
import { env } from '@/lib/core/config/env'
|
||||||
import { REDACTED_MARKER } from '@/lib/core/security/redaction'
|
import { REDACTED_MARKER } from '@/lib/core/security/redaction'
|
||||||
|
|
||||||
const logger = createLogger('SSORegisterRoute')
|
const logger = createLogger('SSO-Register')
|
||||||
|
|
||||||
const mappingSchema = z
|
const mappingSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -43,10 +43,6 @@ const ssoRegistrationSchema = z.discriminatedUnion('providerType', [
|
|||||||
])
|
])
|
||||||
.default(['openid', 'profile', 'email']),
|
.default(['openid', 'profile', 'email']),
|
||||||
pkce: z.boolean().default(true),
|
pkce: z.boolean().default(true),
|
||||||
authorizationEndpoint: z.string().url().optional(),
|
|
||||||
tokenEndpoint: z.string().url().optional(),
|
|
||||||
userInfoEndpoint: z.string().url().optional(),
|
|
||||||
jwksEndpoint: z.string().url().optional(),
|
|
||||||
}),
|
}),
|
||||||
z.object({
|
z.object({
|
||||||
providerType: z.literal('saml'),
|
providerType: z.literal('saml'),
|
||||||
@@ -68,10 +64,12 @@ const ssoRegistrationSchema = z.discriminatedUnion('providerType', [
|
|||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
// SSO plugin must be enabled in Better Auth
|
||||||
if (!env.SSO_ENABLED) {
|
if (!env.SSO_ENABLED) {
|
||||||
return NextResponse.json({ error: 'SSO is not enabled' }, { status: 400 })
|
return NextResponse.json({ error: 'SSO is not enabled' }, { status: 400 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check plan access (enterprise) or env var override
|
||||||
const session = await getSession()
|
const session = await getSession()
|
||||||
if (!session?.user?.id) {
|
if (!session?.user?.id) {
|
||||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||||
@@ -118,16 +116,7 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (providerType === 'oidc') {
|
if (providerType === 'oidc') {
|
||||||
const {
|
const { clientId, clientSecret, scopes, pkce } = body
|
||||||
clientId,
|
|
||||||
clientSecret,
|
|
||||||
scopes,
|
|
||||||
pkce,
|
|
||||||
authorizationEndpoint,
|
|
||||||
tokenEndpoint,
|
|
||||||
userInfoEndpoint,
|
|
||||||
jwksEndpoint,
|
|
||||||
} = body
|
|
||||||
|
|
||||||
const oidcConfig: any = {
|
const oidcConfig: any = {
|
||||||
clientId,
|
clientId,
|
||||||
@@ -138,102 +127,48 @@ export async function POST(request: NextRequest) {
|
|||||||
pkce: pkce ?? true,
|
pkce: pkce ?? true,
|
||||||
}
|
}
|
||||||
|
|
||||||
oidcConfig.authorizationEndpoint = authorizationEndpoint
|
// Add manual endpoints for providers that might need them
|
||||||
oidcConfig.tokenEndpoint = tokenEndpoint
|
// Common patterns for OIDC providers that don't support discovery properly
|
||||||
oidcConfig.userInfoEndpoint = userInfoEndpoint
|
|
||||||
oidcConfig.jwksEndpoint = jwksEndpoint
|
|
||||||
|
|
||||||
const needsDiscovery =
|
|
||||||
!oidcConfig.authorizationEndpoint || !oidcConfig.tokenEndpoint || !oidcConfig.jwksEndpoint
|
|
||||||
|
|
||||||
if (needsDiscovery) {
|
|
||||||
const discoveryUrl = `${issuer.replace(/\/$/, '')}/.well-known/openid-configuration`
|
|
||||||
try {
|
|
||||||
logger.info('Fetching OIDC discovery document for missing endpoints', {
|
|
||||||
discoveryUrl,
|
|
||||||
hasAuthEndpoint: !!oidcConfig.authorizationEndpoint,
|
|
||||||
hasTokenEndpoint: !!oidcConfig.tokenEndpoint,
|
|
||||||
hasJwksEndpoint: !!oidcConfig.jwksEndpoint,
|
|
||||||
})
|
|
||||||
|
|
||||||
const discoveryResponse = await fetch(discoveryUrl, {
|
|
||||||
headers: { Accept: 'application/json' },
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!discoveryResponse.ok) {
|
|
||||||
logger.error('Failed to fetch OIDC discovery document', {
|
|
||||||
status: discoveryResponse.status,
|
|
||||||
statusText: discoveryResponse.statusText,
|
|
||||||
})
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: `Failed to fetch OIDC discovery document from ${discoveryUrl}. Status: ${discoveryResponse.status}. Provide all endpoints explicitly or verify the issuer URL.`,
|
|
||||||
},
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const discovery = await discoveryResponse.json()
|
|
||||||
|
|
||||||
oidcConfig.authorizationEndpoint =
|
|
||||||
oidcConfig.authorizationEndpoint || discovery.authorization_endpoint
|
|
||||||
oidcConfig.tokenEndpoint = oidcConfig.tokenEndpoint || discovery.token_endpoint
|
|
||||||
oidcConfig.userInfoEndpoint = oidcConfig.userInfoEndpoint || discovery.userinfo_endpoint
|
|
||||||
oidcConfig.jwksEndpoint = oidcConfig.jwksEndpoint || discovery.jwks_uri
|
|
||||||
|
|
||||||
logger.info('Merged OIDC endpoints (user-provided + discovery)', {
|
|
||||||
providerId,
|
|
||||||
issuer,
|
|
||||||
authorizationEndpoint: oidcConfig.authorizationEndpoint,
|
|
||||||
tokenEndpoint: oidcConfig.tokenEndpoint,
|
|
||||||
userInfoEndpoint: oidcConfig.userInfoEndpoint,
|
|
||||||
jwksEndpoint: oidcConfig.jwksEndpoint,
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error fetching OIDC discovery document', {
|
|
||||||
error: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
discoveryUrl,
|
|
||||||
})
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: `Failed to fetch OIDC discovery document from ${discoveryUrl}. Please verify the issuer URL is correct or provide all endpoints explicitly.`,
|
|
||||||
},
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.info('Using explicitly provided OIDC endpoints (all present)', {
|
|
||||||
providerId,
|
|
||||||
issuer,
|
|
||||||
authorizationEndpoint: oidcConfig.authorizationEndpoint,
|
|
||||||
tokenEndpoint: oidcConfig.tokenEndpoint,
|
|
||||||
userInfoEndpoint: oidcConfig.userInfoEndpoint,
|
|
||||||
jwksEndpoint: oidcConfig.jwksEndpoint,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!oidcConfig.authorizationEndpoint ||
|
issuer.includes('okta.com') ||
|
||||||
!oidcConfig.tokenEndpoint ||
|
issuer.includes('auth0.com') ||
|
||||||
!oidcConfig.jwksEndpoint
|
issuer.includes('identityserver')
|
||||||
) {
|
) {
|
||||||
const missing: string[] = []
|
const baseUrl = issuer.includes('/oauth2/default')
|
||||||
if (!oidcConfig.authorizationEndpoint) missing.push('authorizationEndpoint')
|
? issuer.replace('/oauth2/default', '')
|
||||||
if (!oidcConfig.tokenEndpoint) missing.push('tokenEndpoint')
|
: issuer.replace('/oauth', '').replace('/v2.0', '').replace('/oauth2', '')
|
||||||
if (!oidcConfig.jwksEndpoint) missing.push('jwksEndpoint')
|
|
||||||
|
|
||||||
logger.error('Missing required OIDC endpoints after discovery merge', {
|
// Okta-style endpoints
|
||||||
missing,
|
if (issuer.includes('okta.com')) {
|
||||||
authorizationEndpoint: oidcConfig.authorizationEndpoint,
|
oidcConfig.authorizationEndpoint = `${baseUrl}/oauth2/default/v1/authorize`
|
||||||
tokenEndpoint: oidcConfig.tokenEndpoint,
|
oidcConfig.tokenEndpoint = `${baseUrl}/oauth2/default/v1/token`
|
||||||
jwksEndpoint: oidcConfig.jwksEndpoint,
|
oidcConfig.userInfoEndpoint = `${baseUrl}/oauth2/default/v1/userinfo`
|
||||||
|
oidcConfig.jwksEndpoint = `${baseUrl}/oauth2/default/v1/keys`
|
||||||
|
}
|
||||||
|
// Auth0-style endpoints
|
||||||
|
else if (issuer.includes('auth0.com')) {
|
||||||
|
oidcConfig.authorizationEndpoint = `${baseUrl}/authorize`
|
||||||
|
oidcConfig.tokenEndpoint = `${baseUrl}/oauth/token`
|
||||||
|
oidcConfig.userInfoEndpoint = `${baseUrl}/userinfo`
|
||||||
|
oidcConfig.jwksEndpoint = `${baseUrl}/.well-known/jwks.json`
|
||||||
|
}
|
||||||
|
// Generic OIDC endpoints (IdentityServer, etc.)
|
||||||
|
else {
|
||||||
|
oidcConfig.authorizationEndpoint = `${baseUrl}/connect/authorize`
|
||||||
|
oidcConfig.tokenEndpoint = `${baseUrl}/connect/token`
|
||||||
|
oidcConfig.userInfoEndpoint = `${baseUrl}/connect/userinfo`
|
||||||
|
oidcConfig.jwksEndpoint = `${baseUrl}/.well-known/jwks`
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('Using manual OIDC endpoints for provider', {
|
||||||
|
providerId,
|
||||||
|
provider: issuer.includes('okta.com')
|
||||||
|
? 'Okta'
|
||||||
|
: issuer.includes('auth0.com')
|
||||||
|
? 'Auth0'
|
||||||
|
: 'Generic',
|
||||||
|
authEndpoint: oidcConfig.authorizationEndpoint,
|
||||||
})
|
})
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
error: `Missing required OIDC endpoints: ${missing.join(', ')}. Please provide these explicitly or verify the issuer supports OIDC discovery.`,
|
|
||||||
},
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
providerConfig.oidcConfig = oidcConfig
|
providerConfig.oidcConfig = oidcConfig
|
||||||
|
|||||||
@@ -550,8 +550,6 @@ export interface AdminUserBilling {
|
|||||||
totalWebhookTriggers: number
|
totalWebhookTriggers: number
|
||||||
totalScheduledExecutions: number
|
totalScheduledExecutions: number
|
||||||
totalChatExecutions: number
|
totalChatExecutions: number
|
||||||
totalMcpExecutions: number
|
|
||||||
totalA2aExecutions: number
|
|
||||||
totalTokensUsed: number
|
totalTokensUsed: number
|
||||||
totalCost: string
|
totalCost: string
|
||||||
currentUsageLimit: string | null
|
currentUsageLimit: string | null
|
||||||
|
|||||||
@@ -97,8 +97,6 @@ export const GET = withAdminAuthParams<RouteParams>(async (_, context) => {
|
|||||||
totalWebhookTriggers: stats?.totalWebhookTriggers ?? 0,
|
totalWebhookTriggers: stats?.totalWebhookTriggers ?? 0,
|
||||||
totalScheduledExecutions: stats?.totalScheduledExecutions ?? 0,
|
totalScheduledExecutions: stats?.totalScheduledExecutions ?? 0,
|
||||||
totalChatExecutions: stats?.totalChatExecutions ?? 0,
|
totalChatExecutions: stats?.totalChatExecutions ?? 0,
|
||||||
totalMcpExecutions: stats?.totalMcpExecutions ?? 0,
|
|
||||||
totalA2aExecutions: stats?.totalA2aExecutions ?? 0,
|
|
||||||
totalTokensUsed: stats?.totalTokensUsed ?? 0,
|
totalTokensUsed: stats?.totalTokensUsed ?? 0,
|
||||||
totalCost: stats?.totalCost ?? '0',
|
totalCost: stats?.totalCost ?? '0',
|
||||||
currentUsageLimit: stats?.currentUsageLimit ?? null,
|
currentUsageLimit: stats?.currentUsageLimit ?? null,
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export interface RateLimitResult {
|
|||||||
|
|
||||||
export async function checkRateLimit(
|
export async function checkRateLimit(
|
||||||
request: NextRequest,
|
request: NextRequest,
|
||||||
endpoint: 'logs' | 'logs-detail' | 'workflows' | 'workflow-detail' = 'logs'
|
endpoint: 'logs' | 'logs-detail' = 'logs'
|
||||||
): Promise<RateLimitResult> {
|
): Promise<RateLimitResult> {
|
||||||
try {
|
try {
|
||||||
const auth = await authenticateV1Request(request)
|
const auth = await authenticateV1Request(request)
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
import { db } from '@sim/db'
|
|
||||||
import { permissions, workflow, workflowBlocks } from '@sim/db/schema'
|
|
||||||
import { createLogger } from '@sim/logger'
|
|
||||||
import { and, eq } from 'drizzle-orm'
|
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format'
|
|
||||||
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
|
|
||||||
import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware'
|
|
||||||
|
|
||||||
const logger = createLogger('V1WorkflowDetailsAPI')
|
|
||||||
|
|
||||||
export const revalidate = 0
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
|
||||||
const requestId = crypto.randomUUID().slice(0, 8)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const rateLimit = await checkRateLimit(request, 'workflow-detail')
|
|
||||||
if (!rateLimit.allowed) {
|
|
||||||
return createRateLimitResponse(rateLimit)
|
|
||||||
}
|
|
||||||
|
|
||||||
const userId = rateLimit.userId!
|
|
||||||
const { id } = await params
|
|
||||||
|
|
||||||
logger.info(`[${requestId}] Fetching workflow details for ${id}`, { userId })
|
|
||||||
|
|
||||||
const rows = await db
|
|
||||||
.select({
|
|
||||||
id: workflow.id,
|
|
||||||
name: workflow.name,
|
|
||||||
description: workflow.description,
|
|
||||||
color: workflow.color,
|
|
||||||
folderId: workflow.folderId,
|
|
||||||
workspaceId: workflow.workspaceId,
|
|
||||||
isDeployed: workflow.isDeployed,
|
|
||||||
deployedAt: workflow.deployedAt,
|
|
||||||
runCount: workflow.runCount,
|
|
||||||
lastRunAt: workflow.lastRunAt,
|
|
||||||
variables: workflow.variables,
|
|
||||||
createdAt: workflow.createdAt,
|
|
||||||
updatedAt: workflow.updatedAt,
|
|
||||||
})
|
|
||||||
.from(workflow)
|
|
||||||
.innerJoin(
|
|
||||||
permissions,
|
|
||||||
and(
|
|
||||||
eq(permissions.entityType, 'workspace'),
|
|
||||||
eq(permissions.entityId, workflow.workspaceId),
|
|
||||||
eq(permissions.userId, userId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.where(eq(workflow.id, id))
|
|
||||||
.limit(1)
|
|
||||||
|
|
||||||
const workflowData = rows[0]
|
|
||||||
if (!workflowData) {
|
|
||||||
return NextResponse.json({ error: 'Workflow not found' }, { status: 404 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const blockRows = await db
|
|
||||||
.select({
|
|
||||||
id: workflowBlocks.id,
|
|
||||||
type: workflowBlocks.type,
|
|
||||||
subBlocks: workflowBlocks.subBlocks,
|
|
||||||
})
|
|
||||||
.from(workflowBlocks)
|
|
||||||
.where(eq(workflowBlocks.workflowId, id))
|
|
||||||
|
|
||||||
const blocksRecord = Object.fromEntries(
|
|
||||||
blockRows.map((block) => [block.id, { type: block.type, subBlocks: block.subBlocks }])
|
|
||||||
)
|
|
||||||
const inputs = extractInputFieldsFromBlocks(blocksRecord)
|
|
||||||
|
|
||||||
const response = {
|
|
||||||
id: workflowData.id,
|
|
||||||
name: workflowData.name,
|
|
||||||
description: workflowData.description,
|
|
||||||
color: workflowData.color,
|
|
||||||
folderId: workflowData.folderId,
|
|
||||||
workspaceId: workflowData.workspaceId,
|
|
||||||
isDeployed: workflowData.isDeployed,
|
|
||||||
deployedAt: workflowData.deployedAt?.toISOString() || null,
|
|
||||||
runCount: workflowData.runCount,
|
|
||||||
lastRunAt: workflowData.lastRunAt?.toISOString() || null,
|
|
||||||
variables: workflowData.variables || {},
|
|
||||||
inputs,
|
|
||||||
createdAt: workflowData.createdAt.toISOString(),
|
|
||||||
updatedAt: workflowData.updatedAt.toISOString(),
|
|
||||||
}
|
|
||||||
|
|
||||||
const limits = await getUserLimits(userId)
|
|
||||||
|
|
||||||
const apiResponse = createApiResponse({ data: response }, limits, rateLimit)
|
|
||||||
|
|
||||||
return NextResponse.json(apiResponse.body, { headers: apiResponse.headers })
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
logger.error(`[${requestId}] Workflow details fetch error`, { error: message })
|
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
import { db } from '@sim/db'
|
|
||||||
import { permissions, workflow } from '@sim/db/schema'
|
|
||||||
import { createLogger } from '@sim/logger'
|
|
||||||
import { and, asc, eq, gt, or } from 'drizzle-orm'
|
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
|
||||||
import { z } from 'zod'
|
|
||||||
import { createApiResponse, getUserLimits } from '@/app/api/v1/logs/meta'
|
|
||||||
import { checkRateLimit, createRateLimitResponse } from '@/app/api/v1/middleware'
|
|
||||||
|
|
||||||
const logger = createLogger('V1WorkflowsAPI')
|
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic'
|
|
||||||
export const revalidate = 0
|
|
||||||
|
|
||||||
const QueryParamsSchema = z.object({
|
|
||||||
workspaceId: z.string(),
|
|
||||||
folderId: z.string().optional(),
|
|
||||||
deployedOnly: z.coerce.boolean().optional().default(false),
|
|
||||||
limit: z.coerce.number().min(1).max(100).optional().default(50),
|
|
||||||
cursor: z.string().optional(),
|
|
||||||
})
|
|
||||||
|
|
||||||
interface CursorData {
|
|
||||||
sortOrder: number
|
|
||||||
createdAt: string
|
|
||||||
id: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function encodeCursor(data: CursorData): string {
|
|
||||||
return Buffer.from(JSON.stringify(data)).toString('base64')
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeCursor(cursor: string): CursorData | null {
|
|
||||||
try {
|
|
||||||
return JSON.parse(Buffer.from(cursor, 'base64').toString())
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
|
||||||
const requestId = crypto.randomUUID().slice(0, 8)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const rateLimit = await checkRateLimit(request, 'workflows')
|
|
||||||
if (!rateLimit.allowed) {
|
|
||||||
return createRateLimitResponse(rateLimit)
|
|
||||||
}
|
|
||||||
|
|
||||||
const userId = rateLimit.userId!
|
|
||||||
const { searchParams } = new URL(request.url)
|
|
||||||
const rawParams = Object.fromEntries(searchParams.entries())
|
|
||||||
|
|
||||||
const validationResult = QueryParamsSchema.safeParse(rawParams)
|
|
||||||
if (!validationResult.success) {
|
|
||||||
return NextResponse.json(
|
|
||||||
{ error: 'Invalid parameters', details: validationResult.error.errors },
|
|
||||||
{ status: 400 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = validationResult.data
|
|
||||||
|
|
||||||
logger.info(`[${requestId}] Fetching workflows for workspace ${params.workspaceId}`, {
|
|
||||||
userId,
|
|
||||||
filters: {
|
|
||||||
folderId: params.folderId,
|
|
||||||
deployedOnly: params.deployedOnly,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const conditions = [
|
|
||||||
eq(workflow.workspaceId, params.workspaceId),
|
|
||||||
eq(permissions.entityType, 'workspace'),
|
|
||||||
eq(permissions.entityId, params.workspaceId),
|
|
||||||
eq(permissions.userId, userId),
|
|
||||||
]
|
|
||||||
|
|
||||||
if (params.folderId) {
|
|
||||||
conditions.push(eq(workflow.folderId, params.folderId))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params.deployedOnly) {
|
|
||||||
conditions.push(eq(workflow.isDeployed, true))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (params.cursor) {
|
|
||||||
const cursorData = decodeCursor(params.cursor)
|
|
||||||
if (cursorData) {
|
|
||||||
const cursorCondition = or(
|
|
||||||
gt(workflow.sortOrder, cursorData.sortOrder),
|
|
||||||
and(
|
|
||||||
eq(workflow.sortOrder, cursorData.sortOrder),
|
|
||||||
gt(workflow.createdAt, new Date(cursorData.createdAt))
|
|
||||||
),
|
|
||||||
and(
|
|
||||||
eq(workflow.sortOrder, cursorData.sortOrder),
|
|
||||||
eq(workflow.createdAt, new Date(cursorData.createdAt)),
|
|
||||||
gt(workflow.id, cursorData.id)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if (cursorCondition) {
|
|
||||||
conditions.push(cursorCondition)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const orderByClause = [asc(workflow.sortOrder), asc(workflow.createdAt), asc(workflow.id)]
|
|
||||||
|
|
||||||
const rows = await db
|
|
||||||
.select({
|
|
||||||
id: workflow.id,
|
|
||||||
name: workflow.name,
|
|
||||||
description: workflow.description,
|
|
||||||
color: workflow.color,
|
|
||||||
folderId: workflow.folderId,
|
|
||||||
workspaceId: workflow.workspaceId,
|
|
||||||
isDeployed: workflow.isDeployed,
|
|
||||||
deployedAt: workflow.deployedAt,
|
|
||||||
runCount: workflow.runCount,
|
|
||||||
lastRunAt: workflow.lastRunAt,
|
|
||||||
sortOrder: workflow.sortOrder,
|
|
||||||
createdAt: workflow.createdAt,
|
|
||||||
updatedAt: workflow.updatedAt,
|
|
||||||
})
|
|
||||||
.from(workflow)
|
|
||||||
.innerJoin(
|
|
||||||
permissions,
|
|
||||||
and(
|
|
||||||
eq(permissions.entityType, 'workspace'),
|
|
||||||
eq(permissions.entityId, params.workspaceId),
|
|
||||||
eq(permissions.userId, userId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.where(and(...conditions))
|
|
||||||
.orderBy(...orderByClause)
|
|
||||||
.limit(params.limit + 1)
|
|
||||||
|
|
||||||
const hasMore = rows.length > params.limit
|
|
||||||
const data = rows.slice(0, params.limit)
|
|
||||||
|
|
||||||
let nextCursor: string | undefined
|
|
||||||
if (hasMore && data.length > 0) {
|
|
||||||
const lastWorkflow = data[data.length - 1]
|
|
||||||
nextCursor = encodeCursor({
|
|
||||||
sortOrder: lastWorkflow.sortOrder,
|
|
||||||
createdAt: lastWorkflow.createdAt.toISOString(),
|
|
||||||
id: lastWorkflow.id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const formattedWorkflows = data.map((w) => ({
|
|
||||||
id: w.id,
|
|
||||||
name: w.name,
|
|
||||||
description: w.description,
|
|
||||||
color: w.color,
|
|
||||||
folderId: w.folderId,
|
|
||||||
workspaceId: w.workspaceId,
|
|
||||||
isDeployed: w.isDeployed,
|
|
||||||
deployedAt: w.deployedAt?.toISOString() || null,
|
|
||||||
runCount: w.runCount,
|
|
||||||
lastRunAt: w.lastRunAt?.toISOString() || null,
|
|
||||||
createdAt: w.createdAt.toISOString(),
|
|
||||||
updatedAt: w.updatedAt.toISOString(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
const limits = await getUserLimits(userId)
|
|
||||||
|
|
||||||
const response = createApiResponse(
|
|
||||||
{
|
|
||||||
data: formattedWorkflows,
|
|
||||||
nextCursor,
|
|
||||||
},
|
|
||||||
limits,
|
|
||||||
rateLimit
|
|
||||||
)
|
|
||||||
|
|
||||||
return NextResponse.json(response.body, { headers: response.headers })
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
logger.error(`[${requestId}] Workflows fetch error`, { error: message })
|
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useRef, useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Label,
|
Label,
|
||||||
@@ -13,7 +14,7 @@ import {
|
|||||||
Textarea,
|
Textarea,
|
||||||
} from '@/components/emcn'
|
} from '@/components/emcn'
|
||||||
import type { DocumentData } from '@/lib/knowledge/types'
|
import type { DocumentData } from '@/lib/knowledge/types'
|
||||||
import { useCreateChunk } from '@/hooks/queries/knowledge'
|
import { knowledgeKeys } from '@/hooks/queries/knowledge'
|
||||||
|
|
||||||
const logger = createLogger('CreateChunkModal')
|
const logger = createLogger('CreateChunkModal')
|
||||||
|
|
||||||
@@ -30,20 +31,16 @@ export function CreateChunkModal({
|
|||||||
document,
|
document,
|
||||||
knowledgeBaseId,
|
knowledgeBaseId,
|
||||||
}: CreateChunkModalProps) {
|
}: CreateChunkModalProps) {
|
||||||
const {
|
const queryClient = useQueryClient()
|
||||||
mutate: createChunk,
|
|
||||||
isPending: isCreating,
|
|
||||||
error: mutationError,
|
|
||||||
reset: resetMutation,
|
|
||||||
} = useCreateChunk()
|
|
||||||
const [content, setContent] = useState('')
|
const [content, setContent] = useState('')
|
||||||
|
const [isCreating, setIsCreating] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false)
|
const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false)
|
||||||
const isProcessingRef = useRef(false)
|
const isProcessingRef = useRef(false)
|
||||||
|
|
||||||
const error = mutationError?.message ?? null
|
|
||||||
const hasUnsavedChanges = content.trim().length > 0
|
const hasUnsavedChanges = content.trim().length > 0
|
||||||
|
|
||||||
const handleCreateChunk = () => {
|
const handleCreateChunk = async () => {
|
||||||
if (!document || content.trim().length === 0 || isProcessingRef.current) {
|
if (!document || content.trim().length === 0 || isProcessingRef.current) {
|
||||||
if (isProcessingRef.current) {
|
if (isProcessingRef.current) {
|
||||||
logger.warn('Chunk creation already in progress, ignoring duplicate request')
|
logger.warn('Chunk creation already in progress, ignoring duplicate request')
|
||||||
@@ -51,32 +48,57 @@ export function CreateChunkModal({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
isProcessingRef.current = true
|
try {
|
||||||
|
isProcessingRef.current = true
|
||||||
|
setIsCreating(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
createChunk(
|
const response = await fetch(
|
||||||
{
|
`/api/knowledge/${knowledgeBaseId}/documents/${document.id}/chunks`,
|
||||||
knowledgeBaseId,
|
{
|
||||||
documentId: document.id,
|
method: 'POST',
|
||||||
content: content.trim(),
|
headers: {
|
||||||
enabled: true,
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
{
|
body: JSON.stringify({
|
||||||
onSuccess: () => {
|
content: content.trim(),
|
||||||
isProcessingRef.current = false
|
enabled: true,
|
||||||
onClose()
|
}),
|
||||||
},
|
}
|
||||||
onError: () => {
|
)
|
||||||
isProcessingRef.current = false
|
|
||||||
},
|
if (!response.ok) {
|
||||||
|
const result = await response.json()
|
||||||
|
throw new Error(result.error || 'Failed to create chunk')
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (result.success && result.data) {
|
||||||
|
logger.info('Chunk created successfully:', result.data.id)
|
||||||
|
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: knowledgeKeys.detail(knowledgeBaseId),
|
||||||
|
})
|
||||||
|
|
||||||
|
onClose()
|
||||||
|
} else {
|
||||||
|
throw new Error(result.error || 'Failed to create chunk')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Error creating chunk:', err)
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred')
|
||||||
|
} finally {
|
||||||
|
isProcessingRef.current = false
|
||||||
|
setIsCreating(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onClose = () => {
|
const onClose = () => {
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
setContent('')
|
setContent('')
|
||||||
|
setError(null)
|
||||||
setShowUnsavedChangesAlert(false)
|
setShowUnsavedChangesAlert(false)
|
||||||
resetMutation()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCloseAttempt = () => {
|
const handleCloseAttempt = () => {
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { createLogger } from '@sim/logger'
|
||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn'
|
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn'
|
||||||
import type { ChunkData } from '@/lib/knowledge/types'
|
import type { ChunkData } from '@/lib/knowledge/types'
|
||||||
import { useDeleteChunk } from '@/hooks/queries/knowledge'
|
import { knowledgeKeys } from '@/hooks/queries/knowledge'
|
||||||
|
|
||||||
|
const logger = createLogger('DeleteChunkModal')
|
||||||
|
|
||||||
interface DeleteChunkModalProps {
|
interface DeleteChunkModalProps {
|
||||||
chunk: ChunkData | null
|
chunk: ChunkData | null
|
||||||
@@ -19,12 +24,44 @@ export function DeleteChunkModal({
|
|||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
}: DeleteChunkModalProps) {
|
}: DeleteChunkModalProps) {
|
||||||
const { mutate: deleteChunk, isPending: isDeleting } = useDeleteChunk()
|
const queryClient = useQueryClient()
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
|
|
||||||
const handleDeleteChunk = () => {
|
const handleDeleteChunk = async () => {
|
||||||
if (!chunk || isDeleting) return
|
if (!chunk || isDeleting) return
|
||||||
|
|
||||||
deleteChunk({ knowledgeBaseId, documentId, chunkId: chunk.id }, { onSuccess: onClose })
|
try {
|
||||||
|
setIsDeleting(true)
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/knowledge/${knowledgeBaseId}/documents/${documentId}/chunks/${chunk.id}`,
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to delete chunk')
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
logger.info('Chunk deleted successfully:', chunk.id)
|
||||||
|
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: knowledgeKeys.detail(knowledgeBaseId),
|
||||||
|
})
|
||||||
|
|
||||||
|
onClose()
|
||||||
|
} else {
|
||||||
|
throw new Error(result.error || 'Failed to delete chunk')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Error deleting chunk:', err)
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!chunk) return null
|
if (!chunk) return null
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import {
|
|||||||
} from '@/hooks/kb/use-knowledge-base-tag-definitions'
|
} from '@/hooks/kb/use-knowledge-base-tag-definitions'
|
||||||
import { useNextAvailableSlot } from '@/hooks/kb/use-next-available-slot'
|
import { useNextAvailableSlot } from '@/hooks/kb/use-next-available-slot'
|
||||||
import { type TagDefinitionInput, useTagDefinitions } from '@/hooks/kb/use-tag-definitions'
|
import { type TagDefinitionInput, useTagDefinitions } from '@/hooks/kb/use-tag-definitions'
|
||||||
import { useUpdateDocumentTags } from '@/hooks/queries/knowledge'
|
|
||||||
|
|
||||||
const logger = createLogger('DocumentTagsModal')
|
const logger = createLogger('DocumentTagsModal')
|
||||||
|
|
||||||
@@ -59,6 +58,8 @@ function formatValueForDisplay(value: string, fieldType: string): string {
|
|||||||
try {
|
try {
|
||||||
const date = new Date(value)
|
const date = new Date(value)
|
||||||
if (Number.isNaN(date.getTime())) return value
|
if (Number.isNaN(date.getTime())) return value
|
||||||
|
// For UTC dates, display the UTC date to prevent timezone shifts
|
||||||
|
// e.g., 2002-05-16T00:00:00.000Z should show as "May 16, 2002" not "May 15, 2002"
|
||||||
if (typeof value === 'string' && (value.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(value))) {
|
if (typeof value === 'string' && (value.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(value))) {
|
||||||
return new Date(
|
return new Date(
|
||||||
date.getUTCFullYear(),
|
date.getUTCFullYear(),
|
||||||
@@ -95,7 +96,6 @@ export function DocumentTagsModal({
|
|||||||
const documentTagHook = useTagDefinitions(knowledgeBaseId, documentId)
|
const documentTagHook = useTagDefinitions(knowledgeBaseId, documentId)
|
||||||
const kbTagHook = useKnowledgeBaseTagDefinitions(knowledgeBaseId)
|
const kbTagHook = useKnowledgeBaseTagDefinitions(knowledgeBaseId)
|
||||||
const { getNextAvailableSlot: getServerNextSlot } = useNextAvailableSlot(knowledgeBaseId)
|
const { getNextAvailableSlot: getServerNextSlot } = useNextAvailableSlot(knowledgeBaseId)
|
||||||
const { mutateAsync: updateDocumentTags } = useUpdateDocumentTags()
|
|
||||||
|
|
||||||
const { saveTagDefinitions, tagDefinitions, fetchTagDefinitions } = documentTagHook
|
const { saveTagDefinitions, tagDefinitions, fetchTagDefinitions } = documentTagHook
|
||||||
const { tagDefinitions: kbTagDefinitions, fetchTagDefinitions: refreshTagDefinitions } = kbTagHook
|
const { tagDefinitions: kbTagDefinitions, fetchTagDefinitions: refreshTagDefinitions } = kbTagHook
|
||||||
@@ -118,6 +118,7 @@ export function DocumentTagsModal({
|
|||||||
const definition = definitions.find((def) => def.tagSlot === slot)
|
const definition = definitions.find((def) => def.tagSlot === slot)
|
||||||
|
|
||||||
if (rawValue !== null && rawValue !== undefined && definition) {
|
if (rawValue !== null && rawValue !== undefined && definition) {
|
||||||
|
// Convert value to string for storage
|
||||||
const stringValue = String(rawValue).trim()
|
const stringValue = String(rawValue).trim()
|
||||||
if (stringValue) {
|
if (stringValue) {
|
||||||
tags.push({
|
tags.push({
|
||||||
@@ -141,34 +142,41 @@ export function DocumentTagsModal({
|
|||||||
async (tagsToSave: DocumentTag[]) => {
|
async (tagsToSave: DocumentTag[]) => {
|
||||||
if (!documentData) return
|
if (!documentData) return
|
||||||
|
|
||||||
const tagData: Record<string, string> = {}
|
try {
|
||||||
|
const tagData: Record<string, string> = {}
|
||||||
|
|
||||||
ALL_TAG_SLOTS.forEach((slot) => {
|
// Only include tags that have values (omit empty ones)
|
||||||
const tag = tagsToSave.find((t) => t.slot === slot)
|
// Use empty string for slots that should be cleared
|
||||||
if (tag?.value.trim()) {
|
ALL_TAG_SLOTS.forEach((slot) => {
|
||||||
tagData[slot] = tag.value.trim()
|
const tag = tagsToSave.find((t) => t.slot === slot)
|
||||||
} else {
|
if (tag?.value.trim()) {
|
||||||
tagData[slot] = ''
|
tagData[slot] = tag.value.trim()
|
||||||
|
} else {
|
||||||
|
// Use empty string to clear a tag (API schema expects string, not null)
|
||||||
|
tagData[slot] = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/documents/${documentId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(tagData),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to update document tags')
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
await updateDocumentTags({
|
onDocumentUpdate?.(tagData as Record<string, string>)
|
||||||
knowledgeBaseId,
|
await fetchTagDefinitions()
|
||||||
documentId,
|
} catch (error) {
|
||||||
tags: tagData,
|
logger.error('Error updating document tags:', error)
|
||||||
})
|
throw error
|
||||||
|
}
|
||||||
onDocumentUpdate?.(tagData)
|
|
||||||
await fetchTagDefinitions()
|
|
||||||
},
|
},
|
||||||
[
|
[documentData, knowledgeBaseId, documentId, fetchTagDefinitions, onDocumentUpdate]
|
||||||
documentData,
|
|
||||||
knowledgeBaseId,
|
|
||||||
documentId,
|
|
||||||
updateDocumentTags,
|
|
||||||
fetchTagDefinitions,
|
|
||||||
onDocumentUpdate,
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleRemoveTag = async (index: number) => {
|
const handleRemoveTag = async (index: number) => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { ChevronDown, ChevronUp } from 'lucide-react'
|
import { ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -18,7 +19,7 @@ import {
|
|||||||
import type { ChunkData, DocumentData } from '@/lib/knowledge/types'
|
import type { ChunkData, DocumentData } from '@/lib/knowledge/types'
|
||||||
import { getAccurateTokenCount, getTokenStrings } from '@/lib/tokenization/estimators'
|
import { getAccurateTokenCount, getTokenStrings } from '@/lib/tokenization/estimators'
|
||||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||||
import { useUpdateChunk } from '@/hooks/queries/knowledge'
|
import { knowledgeKeys } from '@/hooks/queries/knowledge'
|
||||||
|
|
||||||
const logger = createLogger('EditChunkModal')
|
const logger = createLogger('EditChunkModal')
|
||||||
|
|
||||||
@@ -49,22 +50,17 @@ export function EditChunkModal({
|
|||||||
onNavigateToPage,
|
onNavigateToPage,
|
||||||
maxChunkSize,
|
maxChunkSize,
|
||||||
}: EditChunkModalProps) {
|
}: EditChunkModalProps) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
const userPermissions = useUserPermissionsContext()
|
const userPermissions = useUserPermissionsContext()
|
||||||
const {
|
|
||||||
mutate: updateChunk,
|
|
||||||
isPending: isSaving,
|
|
||||||
error: mutationError,
|
|
||||||
reset: resetMutation,
|
|
||||||
} = useUpdateChunk()
|
|
||||||
const [editedContent, setEditedContent] = useState(chunk?.content || '')
|
const [editedContent, setEditedContent] = useState(chunk?.content || '')
|
||||||
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [isNavigating, setIsNavigating] = useState(false)
|
const [isNavigating, setIsNavigating] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false)
|
const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false)
|
||||||
const [pendingNavigation, setPendingNavigation] = useState<(() => void) | null>(null)
|
const [pendingNavigation, setPendingNavigation] = useState<(() => void) | null>(null)
|
||||||
const [tokenizerOn, setTokenizerOn] = useState(false)
|
const [tokenizerOn, setTokenizerOn] = useState(false)
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
const error = mutationError?.message ?? null
|
|
||||||
|
|
||||||
const hasUnsavedChanges = editedContent !== (chunk?.content || '')
|
const hasUnsavedChanges = editedContent !== (chunk?.content || '')
|
||||||
|
|
||||||
const tokenStrings = useMemo(() => {
|
const tokenStrings = useMemo(() => {
|
||||||
@@ -106,15 +102,44 @@ export function EditChunkModal({
|
|||||||
const canNavigatePrev = currentChunkIndex > 0 || currentPage > 1
|
const canNavigatePrev = currentChunkIndex > 0 || currentPage > 1
|
||||||
const canNavigateNext = currentChunkIndex < allChunks.length - 1 || currentPage < totalPages
|
const canNavigateNext = currentChunkIndex < allChunks.length - 1 || currentPage < totalPages
|
||||||
|
|
||||||
const handleSaveContent = () => {
|
const handleSaveContent = async () => {
|
||||||
if (!chunk || !document) return
|
if (!chunk || !document) return
|
||||||
|
|
||||||
updateChunk({
|
try {
|
||||||
knowledgeBaseId,
|
setIsSaving(true)
|
||||||
documentId: document.id,
|
setError(null)
|
||||||
chunkId: chunk.id,
|
|
||||||
content: editedContent,
|
const response = await fetch(
|
||||||
})
|
`/api/knowledge/${knowledgeBaseId}/documents/${document.id}/chunks/${chunk.id}`,
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
content: editedContent,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const result = await response.json()
|
||||||
|
throw new Error(result.error || 'Failed to update chunk')
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: knowledgeKeys.detail(knowledgeBaseId),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Error updating chunk:', err)
|
||||||
|
setError(err instanceof Error ? err.message : 'An error occurred')
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const navigateToChunk = async (direction: 'prev' | 'next') => {
|
const navigateToChunk = async (direction: 'prev' | 'next') => {
|
||||||
@@ -140,6 +165,7 @@ export function EditChunkModal({
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(`Error navigating ${direction}:`, err)
|
logger.error(`Error navigating ${direction}:`, err)
|
||||||
|
setError(`Failed to navigate to ${direction === 'prev' ? 'previous' : 'next'} chunk`)
|
||||||
} finally {
|
} finally {
|
||||||
setIsNavigating(false)
|
setIsNavigating(false)
|
||||||
}
|
}
|
||||||
@@ -159,7 +185,6 @@ export function EditChunkModal({
|
|||||||
setPendingNavigation(null)
|
setPendingNavigation(null)
|
||||||
setShowUnsavedChangesAlert(true)
|
setShowUnsavedChangesAlert(true)
|
||||||
} else {
|
} else {
|
||||||
resetMutation()
|
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -170,7 +195,6 @@ export function EditChunkModal({
|
|||||||
void pendingNavigation()
|
void pendingNavigation()
|
||||||
setPendingNavigation(null)
|
setPendingNavigation(null)
|
||||||
} else {
|
} else {
|
||||||
resetMutation()
|
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,13 +48,7 @@ import { ActionBar } from '@/app/workspace/[workspaceId]/knowledge/[id]/componen
|
|||||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||||
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||||
import { useDocument, useDocumentChunks, useKnowledgeBase } from '@/hooks/kb/use-knowledge'
|
import { useDocument, useDocumentChunks, useKnowledgeBase } from '@/hooks/kb/use-knowledge'
|
||||||
import {
|
import { knowledgeKeys, useDocumentChunkSearchQuery } from '@/hooks/queries/knowledge'
|
||||||
knowledgeKeys,
|
|
||||||
useBulkChunkOperation,
|
|
||||||
useDeleteDocument,
|
|
||||||
useDocumentChunkSearchQuery,
|
|
||||||
useUpdateChunk,
|
|
||||||
} from '@/hooks/queries/knowledge'
|
|
||||||
|
|
||||||
const logger = createLogger('Document')
|
const logger = createLogger('Document')
|
||||||
|
|
||||||
@@ -409,13 +403,11 @@ export function Document({
|
|||||||
const [isCreateChunkModalOpen, setIsCreateChunkModalOpen] = useState(false)
|
const [isCreateChunkModalOpen, setIsCreateChunkModalOpen] = useState(false)
|
||||||
const [chunkToDelete, setChunkToDelete] = useState<ChunkData | null>(null)
|
const [chunkToDelete, setChunkToDelete] = useState<ChunkData | null>(null)
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
|
||||||
|
const [isBulkOperating, setIsBulkOperating] = useState(false)
|
||||||
const [showDeleteDocumentDialog, setShowDeleteDocumentDialog] = useState(false)
|
const [showDeleteDocumentDialog, setShowDeleteDocumentDialog] = useState(false)
|
||||||
|
const [isDeletingDocument, setIsDeletingDocument] = useState(false)
|
||||||
const [contextMenuChunk, setContextMenuChunk] = useState<ChunkData | null>(null)
|
const [contextMenuChunk, setContextMenuChunk] = useState<ChunkData | null>(null)
|
||||||
|
|
||||||
const { mutate: updateChunkMutation } = useUpdateChunk()
|
|
||||||
const { mutate: deleteDocumentMutation, isPending: isDeletingDocument } = useDeleteDocument()
|
|
||||||
const { mutate: bulkChunkMutation, isPending: isBulkOperating } = useBulkChunkOperation()
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isOpen: isContextMenuOpen,
|
isOpen: isContextMenuOpen,
|
||||||
position: contextMenuPosition,
|
position: contextMenuPosition,
|
||||||
@@ -448,23 +440,36 @@ export function Document({
|
|||||||
setSelectedChunk(null)
|
setSelectedChunk(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleToggleEnabled = (chunkId: string) => {
|
const handleToggleEnabled = async (chunkId: string) => {
|
||||||
const chunk = displayChunks.find((c) => c.id === chunkId)
|
const chunk = displayChunks.find((c) => c.id === chunkId)
|
||||||
if (!chunk) return
|
if (!chunk) return
|
||||||
|
|
||||||
updateChunkMutation(
|
try {
|
||||||
{
|
const response = await fetch(
|
||||||
knowledgeBaseId,
|
`/api/knowledge/${knowledgeBaseId}/documents/${documentId}/chunks/${chunkId}`,
|
||||||
documentId,
|
{
|
||||||
chunkId,
|
method: 'PUT',
|
||||||
enabled: !chunk.enabled,
|
headers: {
|
||||||
},
|
'Content-Type': 'application/json',
|
||||||
{
|
},
|
||||||
onSuccess: () => {
|
body: JSON.stringify({
|
||||||
updateChunk(chunkId, { enabled: !chunk.enabled })
|
enabled: !chunk.enabled,
|
||||||
},
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to update chunk')
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
updateChunk(chunkId, { enabled: !chunk.enabled })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Error updating chunk:', err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteChunk = (chunkId: string) => {
|
const handleDeleteChunk = (chunkId: string) => {
|
||||||
@@ -510,65 +515,107 @@ export function Document({
|
|||||||
/**
|
/**
|
||||||
* Handles deleting the document
|
* Handles deleting the document
|
||||||
*/
|
*/
|
||||||
const handleDeleteDocument = () => {
|
const handleDeleteDocument = async () => {
|
||||||
if (!documentData) return
|
if (!documentData) return
|
||||||
|
|
||||||
deleteDocumentMutation(
|
try {
|
||||||
{ knowledgeBaseId, documentId },
|
setIsDeletingDocument(true)
|
||||||
{
|
|
||||||
onSuccess: () => {
|
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/documents/${documentId}`, {
|
||||||
router.push(`/workspace/${workspaceId}/knowledge/${knowledgeBaseId}`)
|
method: 'DELETE',
|
||||||
},
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to delete document')
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: knowledgeKeys.detail(knowledgeBaseId),
|
||||||
|
})
|
||||||
|
|
||||||
|
router.push(`/workspace/${workspaceId}/knowledge/${knowledgeBaseId}`)
|
||||||
|
} else {
|
||||||
|
throw new Error(result.error || 'Failed to delete document')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Error deleting document:', err)
|
||||||
|
setIsDeletingDocument(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const performBulkChunkOperation = (
|
const performBulkChunkOperation = async (
|
||||||
operation: 'enable' | 'disable' | 'delete',
|
operation: 'enable' | 'disable' | 'delete',
|
||||||
chunks: ChunkData[]
|
chunks: ChunkData[]
|
||||||
) => {
|
) => {
|
||||||
if (chunks.length === 0) return
|
if (chunks.length === 0) return
|
||||||
|
|
||||||
bulkChunkMutation(
|
try {
|
||||||
{
|
setIsBulkOperating(true)
|
||||||
knowledgeBaseId,
|
|
||||||
documentId,
|
const response = await fetch(
|
||||||
operation,
|
`/api/knowledge/${knowledgeBaseId}/documents/${documentId}/chunks`,
|
||||||
chunkIds: chunks.map((chunk) => chunk.id),
|
{
|
||||||
},
|
method: 'PATCH',
|
||||||
{
|
headers: {
|
||||||
onSuccess: (result) => {
|
'Content-Type': 'application/json',
|
||||||
if (operation === 'delete' || result.errorCount > 0) {
|
},
|
||||||
refreshChunks()
|
body: JSON.stringify({
|
||||||
} else {
|
operation,
|
||||||
chunks.forEach((chunk) => {
|
chunkIds: chunks.map((chunk) => chunk.id),
|
||||||
updateChunk(chunk.id, { enabled: operation === 'enable' })
|
}),
|
||||||
})
|
}
|
||||||
}
|
)
|
||||||
logger.info(`Successfully ${operation}d ${result.successCount} chunks`)
|
|
||||||
setSelectedChunks(new Set())
|
if (!response.ok) {
|
||||||
},
|
throw new Error(`Failed to ${operation} chunks`)
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
if (operation === 'delete') {
|
||||||
|
await refreshChunks()
|
||||||
|
} else {
|
||||||
|
result.data.results.forEach((opResult: any) => {
|
||||||
|
if (opResult.operation === operation) {
|
||||||
|
opResult.chunkIds.forEach((chunkId: string) => {
|
||||||
|
updateChunk(chunkId, { enabled: operation === 'enable' })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Successfully ${operation}d ${result.data.successCount} chunks`)
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedChunks(new Set())
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`Error ${operation}ing chunks:`, err)
|
||||||
|
} finally {
|
||||||
|
setIsBulkOperating(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleBulkEnable = () => {
|
const handleBulkEnable = async () => {
|
||||||
const chunksToEnable = displayChunks.filter(
|
const chunksToEnable = displayChunks.filter(
|
||||||
(chunk) => selectedChunks.has(chunk.id) && !chunk.enabled
|
(chunk) => selectedChunks.has(chunk.id) && !chunk.enabled
|
||||||
)
|
)
|
||||||
performBulkChunkOperation('enable', chunksToEnable)
|
await performBulkChunkOperation('enable', chunksToEnable)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleBulkDisable = () => {
|
const handleBulkDisable = async () => {
|
||||||
const chunksToDisable = displayChunks.filter(
|
const chunksToDisable = displayChunks.filter(
|
||||||
(chunk) => selectedChunks.has(chunk.id) && chunk.enabled
|
(chunk) => selectedChunks.has(chunk.id) && chunk.enabled
|
||||||
)
|
)
|
||||||
performBulkChunkOperation('disable', chunksToDisable)
|
await performBulkChunkOperation('disable', chunksToDisable)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleBulkDelete = () => {
|
const handleBulkDelete = async () => {
|
||||||
const chunksToDelete = displayChunks.filter((chunk) => selectedChunks.has(chunk.id))
|
const chunksToDelete = displayChunks.filter((chunk) => selectedChunks.has(chunk.id))
|
||||||
performBulkChunkOperation('delete', chunksToDelete)
|
await performBulkChunkOperation('delete', chunksToDelete)
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedChunksList = displayChunks.filter((chunk) => selectedChunks.has(chunk.id))
|
const selectedChunksList = displayChunks.filter((chunk) => selectedChunks.has(chunk.id))
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
@@ -61,12 +62,7 @@ import {
|
|||||||
type TagDefinition,
|
type TagDefinition,
|
||||||
useKnowledgeBaseTagDefinitions,
|
useKnowledgeBaseTagDefinitions,
|
||||||
} from '@/hooks/kb/use-knowledge-base-tag-definitions'
|
} from '@/hooks/kb/use-knowledge-base-tag-definitions'
|
||||||
import {
|
import { knowledgeKeys } from '@/hooks/queries/knowledge'
|
||||||
useBulkDocumentOperation,
|
|
||||||
useDeleteDocument,
|
|
||||||
useDeleteKnowledgeBase,
|
|
||||||
useUpdateDocument,
|
|
||||||
} from '@/hooks/queries/knowledge'
|
|
||||||
|
|
||||||
const logger = createLogger('KnowledgeBase')
|
const logger = createLogger('KnowledgeBase')
|
||||||
|
|
||||||
@@ -411,17 +407,12 @@ export function KnowledgeBase({
|
|||||||
id,
|
id,
|
||||||
knowledgeBaseName: passedKnowledgeBaseName,
|
knowledgeBaseName: passedKnowledgeBaseName,
|
||||||
}: KnowledgeBaseProps) {
|
}: KnowledgeBaseProps) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const workspaceId = params.workspaceId as string
|
const workspaceId = params.workspaceId as string
|
||||||
const { removeKnowledgeBase } = useKnowledgeBasesList(workspaceId, { enabled: false })
|
const { removeKnowledgeBase } = useKnowledgeBasesList(workspaceId, { enabled: false })
|
||||||
const userPermissions = useUserPermissionsContext()
|
const userPermissions = useUserPermissionsContext()
|
||||||
|
|
||||||
const { mutate: updateDocumentMutation } = useUpdateDocument()
|
|
||||||
const { mutate: deleteDocumentMutation } = useDeleteDocument()
|
|
||||||
const { mutate: deleteKnowledgeBaseMutation, isPending: isDeleting } =
|
|
||||||
useDeleteKnowledgeBase(workspaceId)
|
|
||||||
const { mutate: bulkDocumentMutation, isPending: isBulkOperating } = useBulkDocumentOperation()
|
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [showTagsModal, setShowTagsModal] = useState(false)
|
const [showTagsModal, setShowTagsModal] = useState(false)
|
||||||
|
|
||||||
@@ -436,6 +427,8 @@ export function KnowledgeBase({
|
|||||||
const [selectedDocuments, setSelectedDocuments] = useState<Set<string>>(new Set())
|
const [selectedDocuments, setSelectedDocuments] = useState<Set<string>>(new Set())
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||||
const [showAddDocumentsModal, setShowAddDocumentsModal] = useState(false)
|
const [showAddDocumentsModal, setShowAddDocumentsModal] = useState(false)
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
|
const [isBulkOperating, setIsBulkOperating] = useState(false)
|
||||||
const [showDeleteDocumentModal, setShowDeleteDocumentModal] = useState(false)
|
const [showDeleteDocumentModal, setShowDeleteDocumentModal] = useState(false)
|
||||||
const [documentToDelete, setDocumentToDelete] = useState<string | null>(null)
|
const [documentToDelete, setDocumentToDelete] = useState<string | null>(null)
|
||||||
const [showBulkDeleteModal, setShowBulkDeleteModal] = useState(false)
|
const [showBulkDeleteModal, setShowBulkDeleteModal] = useState(false)
|
||||||
@@ -557,7 +550,7 @@ export function KnowledgeBase({
|
|||||||
/**
|
/**
|
||||||
* Checks for documents with stale processing states and marks them as failed
|
* Checks for documents with stale processing states and marks them as failed
|
||||||
*/
|
*/
|
||||||
const checkForDeadProcesses = () => {
|
const checkForDeadProcesses = async () => {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const DEAD_PROCESS_THRESHOLD_MS = 600 * 1000 // 10 minutes
|
const DEAD_PROCESS_THRESHOLD_MS = 600 * 1000 // 10 minutes
|
||||||
|
|
||||||
@@ -574,79 +567,116 @@ export function KnowledgeBase({
|
|||||||
|
|
||||||
logger.warn(`Found ${staleDocuments.length} documents with dead processes`)
|
logger.warn(`Found ${staleDocuments.length} documents with dead processes`)
|
||||||
|
|
||||||
staleDocuments.forEach((doc) => {
|
const markFailedPromises = staleDocuments.map(async (doc) => {
|
||||||
updateDocumentMutation(
|
try {
|
||||||
{
|
const response = await fetch(`/api/knowledge/${id}/documents/${doc.id}`, {
|
||||||
knowledgeBaseId: id,
|
method: 'PUT',
|
||||||
documentId: doc.id,
|
headers: {
|
||||||
updates: { markFailedDueToTimeout: true },
|
'Content-Type': 'application/json',
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
logger.info(`Successfully marked dead process as failed for document: ${doc.filename}`)
|
|
||||||
},
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
markFailedDueToTimeout: true,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({ error: 'Unknown error' }))
|
||||||
|
logger.error(`Failed to mark document ${doc.id} as failed: ${errorData.error}`)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
const result = await response.json()
|
||||||
|
if (result.success) {
|
||||||
|
logger.info(`Successfully marked dead process as failed for document: ${doc.filename}`)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error marking document ${doc.id} as failed:`, error)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await Promise.allSettled(markFailedPromises)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleToggleEnabled = (docId: string) => {
|
const handleToggleEnabled = async (docId: string) => {
|
||||||
const document = documents.find((doc) => doc.id === docId)
|
const document = documents.find((doc) => doc.id === docId)
|
||||||
if (!document) return
|
if (!document) return
|
||||||
|
|
||||||
const newEnabled = !document.enabled
|
const newEnabled = !document.enabled
|
||||||
|
|
||||||
// Optimistic update
|
|
||||||
updateDocument(docId, { enabled: newEnabled })
|
updateDocument(docId, { enabled: newEnabled })
|
||||||
|
|
||||||
updateDocumentMutation(
|
try {
|
||||||
{
|
const response = await fetch(`/api/knowledge/${id}/documents/${docId}`, {
|
||||||
knowledgeBaseId: id,
|
method: 'PUT',
|
||||||
documentId: docId,
|
headers: {
|
||||||
updates: { enabled: newEnabled },
|
'Content-Type': 'application/json',
|
||||||
},
|
|
||||||
{
|
|
||||||
onError: () => {
|
|
||||||
// Rollback on error
|
|
||||||
updateDocument(docId, { enabled: !newEnabled })
|
|
||||||
},
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
enabled: newEnabled,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to update document')
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
updateDocument(docId, { enabled: !newEnabled })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
updateDocument(docId, { enabled: !newEnabled })
|
||||||
|
logger.error('Error updating document:', err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles retrying a failed document processing
|
* Handles retrying a failed document processing
|
||||||
*/
|
*/
|
||||||
const handleRetryDocument = (docId: string) => {
|
const handleRetryDocument = async (docId: string) => {
|
||||||
// Optimistic update
|
try {
|
||||||
updateDocument(docId, {
|
updateDocument(docId, {
|
||||||
processingStatus: 'pending',
|
processingStatus: 'pending',
|
||||||
processingError: null,
|
processingError: null,
|
||||||
processingStartedAt: null,
|
processingStartedAt: null,
|
||||||
processingCompletedAt: null,
|
processingCompletedAt: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
updateDocumentMutation(
|
const response = await fetch(`/api/knowledge/${id}/documents/${docId}`, {
|
||||||
{
|
method: 'PUT',
|
||||||
knowledgeBaseId: id,
|
headers: {
|
||||||
documentId: docId,
|
'Content-Type': 'application/json',
|
||||||
updates: { retryProcessing: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
refreshDocuments()
|
|
||||||
logger.info(`Document retry initiated successfully for: ${docId}`)
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
logger.error('Error retrying document:', err)
|
|
||||||
updateDocument(docId, {
|
|
||||||
processingStatus: 'failed',
|
|
||||||
processingError:
|
|
||||||
err instanceof Error ? err.message : 'Failed to retry document processing',
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
retryProcessing: true,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to retry document processing')
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to retry document processing')
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshDocuments()
|
||||||
|
|
||||||
|
logger.info(`Document retry initiated successfully for: ${docId}`)
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Error retrying document:', err)
|
||||||
|
const currentDoc = documents.find((doc) => doc.id === docId)
|
||||||
|
if (currentDoc) {
|
||||||
|
updateDocument(docId, {
|
||||||
|
processingStatus: 'failed',
|
||||||
|
processingError:
|
||||||
|
err instanceof Error ? err.message : 'Failed to retry document processing',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -664,32 +694,43 @@ export function KnowledgeBase({
|
|||||||
const currentDoc = documents.find((doc) => doc.id === documentId)
|
const currentDoc = documents.find((doc) => doc.id === documentId)
|
||||||
const previousName = currentDoc?.filename
|
const previousName = currentDoc?.filename
|
||||||
|
|
||||||
// Optimistic update
|
|
||||||
updateDocument(documentId, { filename: newName })
|
updateDocument(documentId, { filename: newName })
|
||||||
|
queryClient.setQueryData<DocumentData>(knowledgeKeys.document(id, documentId), (previous) =>
|
||||||
|
previous ? { ...previous, filename: newName } : previous
|
||||||
|
)
|
||||||
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
try {
|
||||||
updateDocumentMutation(
|
const response = await fetch(`/api/knowledge/${id}/documents/${documentId}`, {
|
||||||
{
|
method: 'PUT',
|
||||||
knowledgeBaseId: id,
|
headers: {
|
||||||
documentId,
|
'Content-Type': 'application/json',
|
||||||
updates: { filename: newName },
|
|
||||||
},
|
},
|
||||||
{
|
body: JSON.stringify({ filename: newName }),
|
||||||
onSuccess: () => {
|
})
|
||||||
logger.info(`Document renamed: ${documentId}`)
|
|
||||||
resolve()
|
if (!response.ok) {
|
||||||
},
|
const result = await response.json()
|
||||||
onError: (err) => {
|
throw new Error(result.error || 'Failed to rename document')
|
||||||
// Rollback on error
|
}
|
||||||
if (previousName !== undefined) {
|
|
||||||
updateDocument(documentId, { filename: previousName })
|
const result = await response.json()
|
||||||
}
|
|
||||||
logger.error('Error renaming document:', err)
|
if (!result.success) {
|
||||||
reject(err)
|
throw new Error(result.error || 'Failed to rename document')
|
||||||
},
|
}
|
||||||
}
|
|
||||||
)
|
logger.info(`Document renamed: ${documentId}`)
|
||||||
})
|
} catch (err) {
|
||||||
|
if (previousName !== undefined) {
|
||||||
|
updateDocument(documentId, { filename: previousName })
|
||||||
|
queryClient.setQueryData<DocumentData>(
|
||||||
|
knowledgeKeys.document(id, documentId),
|
||||||
|
(previous) => (previous ? { ...previous, filename: previousName } : previous)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
logger.error('Error renaming document:', err)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -703,26 +744,35 @@ export function KnowledgeBase({
|
|||||||
/**
|
/**
|
||||||
* Confirms and executes the deletion of a single document
|
* Confirms and executes the deletion of a single document
|
||||||
*/
|
*/
|
||||||
const confirmDeleteDocument = () => {
|
const confirmDeleteDocument = async () => {
|
||||||
if (!documentToDelete) return
|
if (!documentToDelete) return
|
||||||
|
|
||||||
deleteDocumentMutation(
|
try {
|
||||||
{ knowledgeBaseId: id, documentId: documentToDelete },
|
const response = await fetch(`/api/knowledge/${id}/documents/${documentToDelete}`, {
|
||||||
{
|
method: 'DELETE',
|
||||||
onSuccess: () => {
|
})
|
||||||
refreshDocuments()
|
|
||||||
setSelectedDocuments((prev) => {
|
if (!response.ok) {
|
||||||
const newSet = new Set(prev)
|
throw new Error('Failed to delete document')
|
||||||
newSet.delete(documentToDelete)
|
|
||||||
return newSet
|
|
||||||
})
|
|
||||||
},
|
|
||||||
onSettled: () => {
|
|
||||||
setShowDeleteDocumentModal(false)
|
|
||||||
setDocumentToDelete(null)
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
refreshDocuments()
|
||||||
|
|
||||||
|
setSelectedDocuments((prev) => {
|
||||||
|
const newSet = new Set(prev)
|
||||||
|
newSet.delete(documentToDelete)
|
||||||
|
return newSet
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Error deleting document:', err)
|
||||||
|
} finally {
|
||||||
|
setShowDeleteDocumentModal(false)
|
||||||
|
setDocumentToDelete(null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -768,18 +818,32 @@ export function KnowledgeBase({
|
|||||||
/**
|
/**
|
||||||
* Handles deleting the entire knowledge base
|
* Handles deleting the entire knowledge base
|
||||||
*/
|
*/
|
||||||
const handleDeleteKnowledgeBase = () => {
|
const handleDeleteKnowledgeBase = async () => {
|
||||||
if (!knowledgeBase) return
|
if (!knowledgeBase) return
|
||||||
|
|
||||||
deleteKnowledgeBaseMutation(
|
try {
|
||||||
{ knowledgeBaseId: id },
|
setIsDeleting(true)
|
||||||
{
|
|
||||||
onSuccess: () => {
|
const response = await fetch(`/api/knowledge/${id}`, {
|
||||||
removeKnowledgeBase(id)
|
method: 'DELETE',
|
||||||
router.push(`/workspace/${workspaceId}/knowledge`)
|
})
|
||||||
},
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to delete knowledge base')
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
removeKnowledgeBase(id)
|
||||||
|
router.push(`/workspace/${workspaceId}/knowledge`)
|
||||||
|
} else {
|
||||||
|
throw new Error(result.error || 'Failed to delete knowledge base')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Error deleting knowledge base:', err)
|
||||||
|
setIsDeleting(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -792,57 +856,93 @@ export function KnowledgeBase({
|
|||||||
/**
|
/**
|
||||||
* Handles bulk enabling of selected documents
|
* Handles bulk enabling of selected documents
|
||||||
*/
|
*/
|
||||||
const handleBulkEnable = () => {
|
const handleBulkEnable = async () => {
|
||||||
const documentsToEnable = documents.filter(
|
const documentsToEnable = documents.filter(
|
||||||
(doc) => selectedDocuments.has(doc.id) && !doc.enabled
|
(doc) => selectedDocuments.has(doc.id) && !doc.enabled
|
||||||
)
|
)
|
||||||
|
|
||||||
if (documentsToEnable.length === 0) return
|
if (documentsToEnable.length === 0) return
|
||||||
|
|
||||||
bulkDocumentMutation(
|
try {
|
||||||
{
|
setIsBulkOperating(true)
|
||||||
knowledgeBaseId: id,
|
|
||||||
operation: 'enable',
|
const response = await fetch(`/api/knowledge/${id}/documents`, {
|
||||||
documentIds: documentsToEnable.map((doc) => doc.id),
|
method: 'PATCH',
|
||||||
},
|
headers: {
|
||||||
{
|
'Content-Type': 'application/json',
|
||||||
onSuccess: (result) => {
|
|
||||||
result.updatedDocuments?.forEach((updatedDoc) => {
|
|
||||||
updateDocument(updatedDoc.id, { enabled: updatedDoc.enabled })
|
|
||||||
})
|
|
||||||
logger.info(`Successfully enabled ${result.successCount} documents`)
|
|
||||||
setSelectedDocuments(new Set())
|
|
||||||
},
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
operation: 'enable',
|
||||||
|
documentIds: documentsToEnable.map((doc) => doc.id),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to enable documents')
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
result.data.updatedDocuments.forEach((updatedDoc: { id: string; enabled: boolean }) => {
|
||||||
|
updateDocument(updatedDoc.id, { enabled: updatedDoc.enabled })
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(`Successfully enabled ${result.data.successCount} documents`)
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedDocuments(new Set())
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Error enabling documents:', err)
|
||||||
|
} finally {
|
||||||
|
setIsBulkOperating(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles bulk disabling of selected documents
|
* Handles bulk disabling of selected documents
|
||||||
*/
|
*/
|
||||||
const handleBulkDisable = () => {
|
const handleBulkDisable = async () => {
|
||||||
const documentsToDisable = documents.filter(
|
const documentsToDisable = documents.filter(
|
||||||
(doc) => selectedDocuments.has(doc.id) && doc.enabled
|
(doc) => selectedDocuments.has(doc.id) && doc.enabled
|
||||||
)
|
)
|
||||||
|
|
||||||
if (documentsToDisable.length === 0) return
|
if (documentsToDisable.length === 0) return
|
||||||
|
|
||||||
bulkDocumentMutation(
|
try {
|
||||||
{
|
setIsBulkOperating(true)
|
||||||
knowledgeBaseId: id,
|
|
||||||
operation: 'disable',
|
const response = await fetch(`/api/knowledge/${id}/documents`, {
|
||||||
documentIds: documentsToDisable.map((doc) => doc.id),
|
method: 'PATCH',
|
||||||
},
|
headers: {
|
||||||
{
|
'Content-Type': 'application/json',
|
||||||
onSuccess: (result) => {
|
|
||||||
result.updatedDocuments?.forEach((updatedDoc) => {
|
|
||||||
updateDocument(updatedDoc.id, { enabled: updatedDoc.enabled })
|
|
||||||
})
|
|
||||||
logger.info(`Successfully disabled ${result.successCount} documents`)
|
|
||||||
setSelectedDocuments(new Set())
|
|
||||||
},
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
operation: 'disable',
|
||||||
|
documentIds: documentsToDisable.map((doc) => doc.id),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to disable documents')
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
result.data.updatedDocuments.forEach((updatedDoc: { id: string; enabled: boolean }) => {
|
||||||
|
updateDocument(updatedDoc.id, { enabled: updatedDoc.enabled })
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(`Successfully disabled ${result.data.successCount} documents`)
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedDocuments(new Set())
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Error disabling documents:', err)
|
||||||
|
} finally {
|
||||||
|
setIsBulkOperating(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -856,28 +956,44 @@ export function KnowledgeBase({
|
|||||||
/**
|
/**
|
||||||
* Confirms and executes the bulk deletion of selected documents
|
* Confirms and executes the bulk deletion of selected documents
|
||||||
*/
|
*/
|
||||||
const confirmBulkDelete = () => {
|
const confirmBulkDelete = async () => {
|
||||||
const documentsToDelete = documents.filter((doc) => selectedDocuments.has(doc.id))
|
const documentsToDelete = documents.filter((doc) => selectedDocuments.has(doc.id))
|
||||||
|
|
||||||
if (documentsToDelete.length === 0) return
|
if (documentsToDelete.length === 0) return
|
||||||
|
|
||||||
bulkDocumentMutation(
|
try {
|
||||||
{
|
setIsBulkOperating(true)
|
||||||
knowledgeBaseId: id,
|
|
||||||
operation: 'delete',
|
const response = await fetch(`/api/knowledge/${id}/documents`, {
|
||||||
documentIds: documentsToDelete.map((doc) => doc.id),
|
method: 'PATCH',
|
||||||
},
|
headers: {
|
||||||
{
|
'Content-Type': 'application/json',
|
||||||
onSuccess: (result) => {
|
|
||||||
logger.info(`Successfully deleted ${result.successCount} documents`)
|
|
||||||
refreshDocuments()
|
|
||||||
setSelectedDocuments(new Set())
|
|
||||||
},
|
|
||||||
onSettled: () => {
|
|
||||||
setShowBulkDeleteModal(false)
|
|
||||||
},
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
operation: 'delete',
|
||||||
|
documentIds: documentsToDelete.map((doc) => doc.id),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to delete documents')
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
logger.info(`Successfully deleted ${result.data.successCount} documents`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await refreshDocuments()
|
||||||
|
|
||||||
|
setSelectedDocuments(new Set())
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Error deleting documents:', err)
|
||||||
|
} finally {
|
||||||
|
setIsBulkOperating(false)
|
||||||
|
setShowBulkDeleteModal(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedDocumentsList = documents.filter((doc) => selectedDocuments.has(doc.id))
|
const selectedDocumentsList = documents.filter((doc) => selectedDocuments.has(doc.id))
|
||||||
|
|||||||
@@ -22,10 +22,10 @@ import {
|
|||||||
type TagDefinition,
|
type TagDefinition,
|
||||||
useKnowledgeBaseTagDefinitions,
|
useKnowledgeBaseTagDefinitions,
|
||||||
} from '@/hooks/kb/use-knowledge-base-tag-definitions'
|
} from '@/hooks/kb/use-knowledge-base-tag-definitions'
|
||||||
import { useCreateTagDefinition, useDeleteTagDefinition } from '@/hooks/queries/knowledge'
|
|
||||||
|
|
||||||
const logger = createLogger('BaseTagsModal')
|
const logger = createLogger('BaseTagsModal')
|
||||||
|
|
||||||
|
/** Field type display labels */
|
||||||
const FIELD_TYPE_LABELS: Record<string, string> = {
|
const FIELD_TYPE_LABELS: Record<string, string> = {
|
||||||
text: 'Text',
|
text: 'Text',
|
||||||
number: 'Number',
|
number: 'Number',
|
||||||
@@ -45,6 +45,7 @@ interface DocumentListProps {
|
|||||||
totalCount: number
|
totalCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Displays a list of documents affected by tag operations */
|
||||||
function DocumentList({ documents, totalCount }: DocumentListProps) {
|
function DocumentList({ documents, totalCount }: DocumentListProps) {
|
||||||
const displayLimit = 5
|
const displayLimit = 5
|
||||||
const hasMore = totalCount > displayLimit
|
const hasMore = totalCount > displayLimit
|
||||||
@@ -94,14 +95,13 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
|||||||
const { tagDefinitions: kbTagDefinitions, fetchTagDefinitions: refreshTagDefinitions } =
|
const { tagDefinitions: kbTagDefinitions, fetchTagDefinitions: refreshTagDefinitions } =
|
||||||
useKnowledgeBaseTagDefinitions(knowledgeBaseId)
|
useKnowledgeBaseTagDefinitions(knowledgeBaseId)
|
||||||
|
|
||||||
const createTagMutation = useCreateTagDefinition()
|
|
||||||
const deleteTagMutation = useDeleteTagDefinition()
|
|
||||||
|
|
||||||
const [deleteTagDialogOpen, setDeleteTagDialogOpen] = useState(false)
|
const [deleteTagDialogOpen, setDeleteTagDialogOpen] = useState(false)
|
||||||
const [selectedTag, setSelectedTag] = useState<TagDefinition | null>(null)
|
const [selectedTag, setSelectedTag] = useState<TagDefinition | null>(null)
|
||||||
const [viewDocumentsDialogOpen, setViewDocumentsDialogOpen] = useState(false)
|
const [viewDocumentsDialogOpen, setViewDocumentsDialogOpen] = useState(false)
|
||||||
|
const [isDeletingTag, setIsDeletingTag] = useState(false)
|
||||||
const [tagUsageData, setTagUsageData] = useState<TagUsageData[]>([])
|
const [tagUsageData, setTagUsageData] = useState<TagUsageData[]>([])
|
||||||
const [isCreatingTag, setIsCreatingTag] = useState(false)
|
const [isCreatingTag, setIsCreatingTag] = useState(false)
|
||||||
|
const [isSavingTag, setIsSavingTag] = useState(false)
|
||||||
const [createTagForm, setCreateTagForm] = useState({
|
const [createTagForm, setCreateTagForm] = useState({
|
||||||
displayName: '',
|
displayName: '',
|
||||||
fieldType: 'text',
|
fieldType: 'text',
|
||||||
@@ -177,12 +177,13 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
|||||||
}
|
}
|
||||||
|
|
||||||
const tagNameConflict =
|
const tagNameConflict =
|
||||||
isCreatingTag && !createTagMutation.isPending && hasTagNameConflict(createTagForm.displayName)
|
isCreatingTag && !isSavingTag && hasTagNameConflict(createTagForm.displayName)
|
||||||
|
|
||||||
const canSaveTag = () => {
|
const canSaveTag = () => {
|
||||||
return createTagForm.displayName.trim() && !hasTagNameConflict(createTagForm.displayName)
|
return createTagForm.displayName.trim() && !hasTagNameConflict(createTagForm.displayName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Get slot usage counts per field type */
|
||||||
const getSlotUsageByFieldType = (fieldType: string): { used: number; max: number } => {
|
const getSlotUsageByFieldType = (fieldType: string): { used: number; max: number } => {
|
||||||
const config = TAG_SLOT_CONFIG[fieldType as keyof typeof TAG_SLOT_CONFIG]
|
const config = TAG_SLOT_CONFIG[fieldType as keyof typeof TAG_SLOT_CONFIG]
|
||||||
if (!config) return { used: 0, max: 0 }
|
if (!config) return { used: 0, max: 0 }
|
||||||
@@ -190,11 +191,13 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
|||||||
return { used, max: config.maxSlots }
|
return { used, max: config.maxSlots }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Check if a field type has available slots */
|
||||||
const hasAvailableSlots = (fieldType: string): boolean => {
|
const hasAvailableSlots = (fieldType: string): boolean => {
|
||||||
const { used, max } = getSlotUsageByFieldType(fieldType)
|
const { used, max } = getSlotUsageByFieldType(fieldType)
|
||||||
return used < max
|
return used < max
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Field type options for Combobox */
|
||||||
const fieldTypeOptions: ComboboxOption[] = useMemo(() => {
|
const fieldTypeOptions: ComboboxOption[] = useMemo(() => {
|
||||||
return SUPPORTED_FIELD_TYPES.filter((type) => hasAvailableSlots(type)).map((type) => {
|
return SUPPORTED_FIELD_TYPES.filter((type) => hasAvailableSlots(type)).map((type) => {
|
||||||
const { used, max } = getSlotUsageByFieldType(type)
|
const { used, max } = getSlotUsageByFieldType(type)
|
||||||
@@ -208,17 +211,43 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
|||||||
const saveTagDefinition = async () => {
|
const saveTagDefinition = async () => {
|
||||||
if (!canSaveTag()) return
|
if (!canSaveTag()) return
|
||||||
|
|
||||||
|
setIsSavingTag(true)
|
||||||
try {
|
try {
|
||||||
|
// Check if selected field type has available slots
|
||||||
if (!hasAvailableSlots(createTagForm.fieldType)) {
|
if (!hasAvailableSlots(createTagForm.fieldType)) {
|
||||||
throw new Error(`No available slots for ${createTagForm.fieldType} type`)
|
throw new Error(`No available slots for ${createTagForm.fieldType} type`)
|
||||||
}
|
}
|
||||||
|
|
||||||
await createTagMutation.mutateAsync({
|
// Get the next available slot from the API
|
||||||
knowledgeBaseId,
|
const slotResponse = await fetch(
|
||||||
|
`/api/knowledge/${knowledgeBaseId}/next-available-slot?fieldType=${createTagForm.fieldType}`
|
||||||
|
)
|
||||||
|
if (!slotResponse.ok) {
|
||||||
|
throw new Error('Failed to get available slot')
|
||||||
|
}
|
||||||
|
const slotResult = await slotResponse.json()
|
||||||
|
if (!slotResult.success || !slotResult.data?.nextAvailableSlot) {
|
||||||
|
throw new Error('No available tag slots for this field type')
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTagDefinition = {
|
||||||
|
tagSlot: slotResult.data.nextAvailableSlot,
|
||||||
displayName: createTagForm.displayName.trim(),
|
displayName: createTagForm.displayName.trim(),
|
||||||
fieldType: createTagForm.fieldType,
|
fieldType: createTagForm.fieldType,
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/tag-definitions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(newTagDefinition),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to create tag definition')
|
||||||
|
}
|
||||||
|
|
||||||
await Promise.all([refreshTagDefinitions(), fetchTagUsage()])
|
await Promise.all([refreshTagDefinitions(), fetchTagUsage()])
|
||||||
|
|
||||||
setCreateTagForm({
|
setCreateTagForm({
|
||||||
@@ -228,17 +257,27 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
|||||||
setIsCreatingTag(false)
|
setIsCreatingTag(false)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error creating tag definition:', error)
|
logger.error('Error creating tag definition:', error)
|
||||||
|
} finally {
|
||||||
|
setIsSavingTag(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const confirmDeleteTag = async () => {
|
const confirmDeleteTag = async () => {
|
||||||
if (!selectedTag) return
|
if (!selectedTag) return
|
||||||
|
|
||||||
|
setIsDeletingTag(true)
|
||||||
try {
|
try {
|
||||||
await deleteTagMutation.mutateAsync({
|
const response = await fetch(
|
||||||
knowledgeBaseId,
|
`/api/knowledge/${knowledgeBaseId}/tag-definitions/${selectedTag.id}`,
|
||||||
tagDefinitionId: selectedTag.id,
|
{
|
||||||
})
|
method: 'DELETE',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text()
|
||||||
|
throw new Error(`Failed to delete tag definition: ${response.status} ${errorText}`)
|
||||||
|
}
|
||||||
|
|
||||||
await Promise.all([refreshTagDefinitions(), fetchTagUsage()])
|
await Promise.all([refreshTagDefinitions(), fetchTagUsage()])
|
||||||
|
|
||||||
@@ -246,6 +285,8 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
|||||||
setSelectedTag(null)
|
setSelectedTag(null)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error deleting tag definition:', error)
|
logger.error('Error deleting tag definition:', error)
|
||||||
|
} finally {
|
||||||
|
setIsDeletingTag(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,11 +433,11 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
|||||||
className='flex-1'
|
className='flex-1'
|
||||||
disabled={
|
disabled={
|
||||||
!canSaveTag() ||
|
!canSaveTag() ||
|
||||||
createTagMutation.isPending ||
|
isSavingTag ||
|
||||||
!hasAvailableSlots(createTagForm.fieldType)
|
!hasAvailableSlots(createTagForm.fieldType)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{createTagMutation.isPending ? 'Creating...' : 'Create Tag'}
|
{isSavingTag ? 'Creating...' : 'Create Tag'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -440,17 +481,13 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
|||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button
|
<Button
|
||||||
variant='default'
|
variant='default'
|
||||||
disabled={deleteTagMutation.isPending}
|
disabled={isDeletingTag}
|
||||||
onClick={() => setDeleteTagDialogOpen(false)}
|
onClick={() => setDeleteTagDialogOpen(false)}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant='destructive' onClick={confirmDeleteTag} disabled={isDeletingTag}>
|
||||||
variant='destructive'
|
{isDeletingTag ? <>Deleting...</> : 'Delete Tag'}
|
||||||
onClick={confirmDeleteTag}
|
|
||||||
disabled={deleteTagMutation.isPending}
|
|
||||||
>
|
|
||||||
{deleteTagMutation.isPending ? 'Deleting...' : 'Delete Tag'}
|
|
||||||
</Button>
|
</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
@@ -462,7 +499,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
|||||||
<ModalHeader>Documents using "{selectedTag?.displayName}"</ModalHeader>
|
<ModalHeader>Documents using "{selectedTag?.displayName}"</ModalHeader>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<div className='space-y-[8px]'>
|
<div className='space-y-[8px]'>
|
||||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||||
{selectedTagUsage?.documentCount || 0} document
|
{selectedTagUsage?.documentCount || 0} document
|
||||||
{selectedTagUsage?.documentCount !== 1 ? 's are' : ' is'} currently using this tag
|
{selectedTagUsage?.documentCount !== 1 ? 's are' : ' is'} currently using this tag
|
||||||
definition.
|
definition.
|
||||||
@@ -470,7 +507,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
|||||||
|
|
||||||
{selectedTagUsage?.documentCount === 0 ? (
|
{selectedTagUsage?.documentCount === 0 ? (
|
||||||
<div className='rounded-[6px] border p-[16px] text-center'>
|
<div className='rounded-[6px] border p-[16px] text-center'>
|
||||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||||
This tag definition is not being used by any documents. You can safely delete it
|
This tag definition is not being used by any documents. You can safely delete it
|
||||||
to free up the tag slot.
|
to free up the tag slot.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { Loader2, RotateCcw, X } from 'lucide-react'
|
import { Loader2, RotateCcw, X } from 'lucide-react'
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams } from 'next/navigation'
|
||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
@@ -22,7 +23,7 @@ import { cn } from '@/lib/core/utils/cn'
|
|||||||
import { formatFileSize, validateKnowledgeBaseFile } from '@/lib/uploads/utils/file-utils'
|
import { formatFileSize, validateKnowledgeBaseFile } from '@/lib/uploads/utils/file-utils'
|
||||||
import { ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
|
import { ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
|
||||||
import { useKnowledgeUpload } from '@/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload'
|
import { useKnowledgeUpload } from '@/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload'
|
||||||
import { useCreateKnowledgeBase, useDeleteKnowledgeBase } from '@/hooks/queries/knowledge'
|
import { knowledgeKeys } from '@/hooks/queries/knowledge'
|
||||||
|
|
||||||
const logger = createLogger('CreateBaseModal')
|
const logger = createLogger('CreateBaseModal')
|
||||||
|
|
||||||
@@ -81,11 +82,10 @@ interface SubmitStatus {
|
|||||||
export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
|
export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const workspaceId = params.workspaceId as string
|
const workspaceId = params.workspaceId as string
|
||||||
|
const queryClient = useQueryClient()
|
||||||
const createKnowledgeBaseMutation = useCreateKnowledgeBase(workspaceId)
|
|
||||||
const deleteKnowledgeBaseMutation = useDeleteKnowledgeBase(workspaceId)
|
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [submitStatus, setSubmitStatus] = useState<SubmitStatus | null>(null)
|
const [submitStatus, setSubmitStatus] = useState<SubmitStatus | null>(null)
|
||||||
const [files, setFiles] = useState<FileWithPreview[]>([])
|
const [files, setFiles] = useState<FileWithPreview[]>([])
|
||||||
const [fileError, setFileError] = useState<string | null>(null)
|
const [fileError, setFileError] = useState<string | null>(null)
|
||||||
@@ -245,14 +245,12 @@ export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const isSubmitting =
|
|
||||||
createKnowledgeBaseMutation.isPending || deleteKnowledgeBaseMutation.isPending || isUploading
|
|
||||||
|
|
||||||
const onSubmit = async (data: FormValues) => {
|
const onSubmit = async (data: FormValues) => {
|
||||||
|
setIsSubmitting(true)
|
||||||
setSubmitStatus(null)
|
setSubmitStatus(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const newKnowledgeBase = await createKnowledgeBaseMutation.mutateAsync({
|
const knowledgeBasePayload = {
|
||||||
name: data.name,
|
name: data.name,
|
||||||
description: data.description || undefined,
|
description: data.description || undefined,
|
||||||
workspaceId: workspaceId,
|
workspaceId: workspaceId,
|
||||||
@@ -261,8 +259,29 @@ export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
|
|||||||
minSize: data.minChunkSize,
|
minSize: data.minChunkSize,
|
||||||
overlap: data.overlapSize,
|
overlap: data.overlapSize,
|
||||||
},
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('/api/knowledge', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(knowledgeBasePayload),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json()
|
||||||
|
throw new Error(errorData.error || 'Failed to create knowledge base')
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to create knowledge base')
|
||||||
|
}
|
||||||
|
|
||||||
|
const newKnowledgeBase = result.data
|
||||||
|
|
||||||
if (files.length > 0) {
|
if (files.length > 0) {
|
||||||
try {
|
try {
|
||||||
const uploadedFiles = await uploadFiles(files, newKnowledgeBase.id, {
|
const uploadedFiles = await uploadFiles(files, newKnowledgeBase.id, {
|
||||||
@@ -274,11 +293,15 @@ export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
|
|||||||
|
|
||||||
logger.info(`Successfully uploaded ${uploadedFiles.length} files`)
|
logger.info(`Successfully uploaded ${uploadedFiles.length} files`)
|
||||||
logger.info(`Started processing ${uploadedFiles.length} documents in the background`)
|
logger.info(`Started processing ${uploadedFiles.length} documents in the background`)
|
||||||
|
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: knowledgeKeys.list(workspaceId),
|
||||||
|
})
|
||||||
} catch (uploadError) {
|
} catch (uploadError) {
|
||||||
logger.error('File upload failed, deleting knowledge base:', uploadError)
|
logger.error('File upload failed, deleting knowledge base:', uploadError)
|
||||||
try {
|
try {
|
||||||
await deleteKnowledgeBaseMutation.mutateAsync({
|
await fetch(`/api/knowledge/${newKnowledgeBase.id}`, {
|
||||||
knowledgeBaseId: newKnowledgeBase.id,
|
method: 'DELETE',
|
||||||
})
|
})
|
||||||
logger.info(`Deleted orphaned knowledge base: ${newKnowledgeBase.id}`)
|
logger.info(`Deleted orphaned knowledge base: ${newKnowledgeBase.id}`)
|
||||||
} catch (deleteError) {
|
} catch (deleteError) {
|
||||||
@@ -286,6 +309,10 @@ export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
|
|||||||
}
|
}
|
||||||
throw uploadError
|
throw uploadError
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: knowledgeKeys.list(workspaceId),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
files.forEach((file) => URL.revokeObjectURL(file.preview))
|
files.forEach((file) => URL.revokeObjectURL(file.preview))
|
||||||
@@ -298,6 +325,8 @@ export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
|
|||||||
type: 'error',
|
type: 'error',
|
||||||
message: error instanceof Error ? error.message : 'An unknown error occurred',
|
message: error instanceof Error ? error.message : 'An unknown error occurred',
|
||||||
})
|
})
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { AlertTriangle, ChevronDown, LibraryBig, MoreHorizontal } from 'lucide-react'
|
import { AlertTriangle, ChevronDown, LibraryBig, MoreHorizontal } from 'lucide-react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import {
|
import {
|
||||||
@@ -14,7 +15,7 @@ import {
|
|||||||
} from '@/components/emcn'
|
} from '@/components/emcn'
|
||||||
import { Trash } from '@/components/emcn/icons/trash'
|
import { Trash } from '@/components/emcn/icons/trash'
|
||||||
import { filterButtonClass } from '@/app/workspace/[workspaceId]/knowledge/components/constants'
|
import { filterButtonClass } from '@/app/workspace/[workspaceId]/knowledge/components/constants'
|
||||||
import { useUpdateKnowledgeBase } from '@/hooks/queries/knowledge'
|
import { knowledgeKeys } from '@/hooks/queries/knowledge'
|
||||||
|
|
||||||
const logger = createLogger('KnowledgeHeader')
|
const logger = createLogger('KnowledgeHeader')
|
||||||
|
|
||||||
@@ -53,13 +54,14 @@ interface Workspace {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function KnowledgeHeader({ breadcrumbs, options }: KnowledgeHeaderProps) {
|
export function KnowledgeHeader({ breadcrumbs, options }: KnowledgeHeaderProps) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
const [isActionsPopoverOpen, setIsActionsPopoverOpen] = useState(false)
|
const [isActionsPopoverOpen, setIsActionsPopoverOpen] = useState(false)
|
||||||
const [isWorkspacePopoverOpen, setIsWorkspacePopoverOpen] = useState(false)
|
const [isWorkspacePopoverOpen, setIsWorkspacePopoverOpen] = useState(false)
|
||||||
const [workspaces, setWorkspaces] = useState<Workspace[]>([])
|
const [workspaces, setWorkspaces] = useState<Workspace[]>([])
|
||||||
const [isLoadingWorkspaces, setIsLoadingWorkspaces] = useState(false)
|
const [isLoadingWorkspaces, setIsLoadingWorkspaces] = useState(false)
|
||||||
|
const [isUpdatingWorkspace, setIsUpdatingWorkspace] = useState(false)
|
||||||
|
|
||||||
const updateKnowledgeBase = useUpdateKnowledgeBase()
|
// Fetch available workspaces
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!options?.knowledgeBaseId) return
|
if (!options?.knowledgeBaseId) return
|
||||||
|
|
||||||
@@ -74,6 +76,7 @@ export function KnowledgeHeader({ breadcrumbs, options }: KnowledgeHeaderProps)
|
|||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
|
|
||||||
|
// Filter workspaces where user has write/admin permissions
|
||||||
const availableWorkspaces = data.workspaces
|
const availableWorkspaces = data.workspaces
|
||||||
.filter((ws: any) => ws.permissions === 'write' || ws.permissions === 'admin')
|
.filter((ws: any) => ws.permissions === 'write' || ws.permissions === 'admin')
|
||||||
.map((ws: any) => ({
|
.map((ws: any) => ({
|
||||||
@@ -94,27 +97,47 @@ export function KnowledgeHeader({ breadcrumbs, options }: KnowledgeHeaderProps)
|
|||||||
}, [options?.knowledgeBaseId])
|
}, [options?.knowledgeBaseId])
|
||||||
|
|
||||||
const handleWorkspaceChange = async (workspaceId: string | null) => {
|
const handleWorkspaceChange = async (workspaceId: string | null) => {
|
||||||
if (updateKnowledgeBase.isPending || !options?.knowledgeBaseId) return
|
if (isUpdatingWorkspace || !options?.knowledgeBaseId) return
|
||||||
|
|
||||||
setIsWorkspacePopoverOpen(false)
|
try {
|
||||||
|
setIsUpdatingWorkspace(true)
|
||||||
|
setIsWorkspacePopoverOpen(false)
|
||||||
|
|
||||||
updateKnowledgeBase.mutate(
|
const response = await fetch(`/api/knowledge/${options.knowledgeBaseId}`, {
|
||||||
{
|
method: 'PUT',
|
||||||
knowledgeBaseId: options.knowledgeBaseId,
|
headers: {
|
||||||
updates: { workspaceId },
|
'Content-Type': 'application/json',
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
logger.info(
|
|
||||||
`Knowledge base workspace updated: ${options.knowledgeBaseId} -> ${workspaceId}`
|
|
||||||
)
|
|
||||||
options.onWorkspaceChange?.(workspaceId)
|
|
||||||
},
|
|
||||||
onError: (err) => {
|
|
||||||
logger.error('Error updating workspace:', err)
|
|
||||||
},
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
workspaceId,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const result = await response.json()
|
||||||
|
throw new Error(result.error || 'Failed to update workspace')
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
logger.info(
|
||||||
|
`Knowledge base workspace updated: ${options.knowledgeBaseId} -> ${workspaceId}`
|
||||||
|
)
|
||||||
|
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: knowledgeKeys.detail(options.knowledgeBaseId),
|
||||||
|
})
|
||||||
|
|
||||||
|
await options.onWorkspaceChange?.(workspaceId)
|
||||||
|
} else {
|
||||||
|
throw new Error(result.error || 'Failed to update workspace')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Error updating workspace:', err)
|
||||||
|
} finally {
|
||||||
|
setIsUpdatingWorkspace(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentWorkspace = workspaces.find((ws) => ws.id === options?.currentWorkspaceId)
|
const currentWorkspace = workspaces.find((ws) => ws.id === options?.currentWorkspaceId)
|
||||||
@@ -124,6 +147,7 @@ export function KnowledgeHeader({ breadcrumbs, options }: KnowledgeHeaderProps)
|
|||||||
<div className={HEADER_STYLES.container}>
|
<div className={HEADER_STYLES.container}>
|
||||||
<div className={HEADER_STYLES.breadcrumbs}>
|
<div className={HEADER_STYLES.breadcrumbs}>
|
||||||
{breadcrumbs.map((breadcrumb, index) => {
|
{breadcrumbs.map((breadcrumb, index) => {
|
||||||
|
// Use unique identifier when available, fallback to content-based key
|
||||||
const key = breadcrumb.id || `${breadcrumb.label}-${breadcrumb.href || index}`
|
const key = breadcrumb.id || `${breadcrumb.label}-${breadcrumb.href || index}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -165,13 +189,13 @@ export function KnowledgeHeader({ breadcrumbs, options }: KnowledgeHeaderProps)
|
|||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant='outline'
|
variant='outline'
|
||||||
disabled={isLoadingWorkspaces || updateKnowledgeBase.isPending}
|
disabled={isLoadingWorkspaces || isUpdatingWorkspace}
|
||||||
className={filterButtonClass}
|
className={filterButtonClass}
|
||||||
>
|
>
|
||||||
<span className='truncate'>
|
<span className='truncate'>
|
||||||
{isLoadingWorkspaces
|
{isLoadingWorkspaces
|
||||||
? 'Loading...'
|
? 'Loading...'
|
||||||
: updateKnowledgeBase.isPending
|
: isUpdatingWorkspace
|
||||||
? 'Updating...'
|
? 'Updating...'
|
||||||
: currentWorkspace?.name || 'No workspace'}
|
: currentWorkspace?.name || 'No workspace'}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ import {
|
|||||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||||
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||||
import { useKnowledgeBasesList } from '@/hooks/kb/use-knowledge'
|
import { useKnowledgeBasesList } from '@/hooks/kb/use-knowledge'
|
||||||
import { useDeleteKnowledgeBase, useUpdateKnowledgeBase } from '@/hooks/queries/knowledge'
|
|
||||||
import { useDebounce } from '@/hooks/use-debounce'
|
import { useDebounce } from '@/hooks/use-debounce'
|
||||||
|
|
||||||
const logger = createLogger('Knowledge')
|
const logger = createLogger('Knowledge')
|
||||||
@@ -52,12 +51,10 @@ export function Knowledge() {
|
|||||||
const params = useParams()
|
const params = useParams()
|
||||||
const workspaceId = params.workspaceId as string
|
const workspaceId = params.workspaceId as string
|
||||||
|
|
||||||
const { knowledgeBases, isLoading, error } = useKnowledgeBasesList(workspaceId)
|
const { knowledgeBases, isLoading, error, removeKnowledgeBase, updateKnowledgeBase } =
|
||||||
|
useKnowledgeBasesList(workspaceId)
|
||||||
const userPermissions = useUserPermissionsContext()
|
const userPermissions = useUserPermissionsContext()
|
||||||
|
|
||||||
const { mutateAsync: updateKnowledgeBaseMutation } = useUpdateKnowledgeBase(workspaceId)
|
|
||||||
const { mutateAsync: deleteKnowledgeBaseMutation } = useDeleteKnowledgeBase(workspaceId)
|
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const debouncedSearchQuery = useDebounce(searchQuery, 300)
|
const debouncedSearchQuery = useDebounce(searchQuery, 300)
|
||||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
|
||||||
@@ -115,13 +112,29 @@ export function Knowledge() {
|
|||||||
*/
|
*/
|
||||||
const handleUpdateKnowledgeBase = useCallback(
|
const handleUpdateKnowledgeBase = useCallback(
|
||||||
async (id: string, name: string, description: string) => {
|
async (id: string, name: string, description: string) => {
|
||||||
await updateKnowledgeBaseMutation({
|
const response = await fetch(`/api/knowledge/${id}`, {
|
||||||
knowledgeBaseId: id,
|
method: 'PUT',
|
||||||
updates: { name, description },
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ name, description }),
|
||||||
})
|
})
|
||||||
logger.info(`Knowledge base updated: ${id}`)
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const result = await response.json()
|
||||||
|
throw new Error(result.error || 'Failed to update knowledge base')
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
logger.info(`Knowledge base updated: ${id}`)
|
||||||
|
updateKnowledgeBase(id, { name, description })
|
||||||
|
} else {
|
||||||
|
throw new Error(result.error || 'Failed to update knowledge base')
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[updateKnowledgeBaseMutation]
|
[updateKnowledgeBase]
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -129,10 +142,25 @@ export function Knowledge() {
|
|||||||
*/
|
*/
|
||||||
const handleDeleteKnowledgeBase = useCallback(
|
const handleDeleteKnowledgeBase = useCallback(
|
||||||
async (id: string) => {
|
async (id: string) => {
|
||||||
await deleteKnowledgeBaseMutation({ knowledgeBaseId: id })
|
const response = await fetch(`/api/knowledge/${id}`, {
|
||||||
logger.info(`Knowledge base deleted: ${id}`)
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const result = await response.json()
|
||||||
|
throw new Error(result.error || 'Failed to delete knowledge base')
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json()
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
logger.info(`Knowledge base deleted: ${id}`)
|
||||||
|
removeKnowledgeBase(id)
|
||||||
|
} else {
|
||||||
|
throw new Error(result.error || 'Failed to delete knowledge base')
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[deleteKnowledgeBaseMutation]
|
[removeKnowledgeBase]
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
import { PopoverSection } from '@/components/emcn'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Skeleton loading component for chat history dropdown
|
|
||||||
* Displays placeholder content while chats are being loaded
|
|
||||||
*/
|
|
||||||
export function ChatHistorySkeleton() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PopoverSection>
|
|
||||||
<div className='h-3 w-12 animate-pulse rounded bg-muted/40' />
|
|
||||||
</PopoverSection>
|
|
||||||
<div className='flex flex-col gap-0.5'>
|
|
||||||
{[1, 2, 3].map((i) => (
|
|
||||||
<div key={i} className='flex h-[25px] items-center px-[6px]'>
|
|
||||||
<div className='h-3 w-full animate-pulse rounded bg-muted/40' />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from './chat-history-skeleton'
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import { Button } from '@/components/emcn'
|
|
||||||
|
|
||||||
type CheckpointConfirmationVariant = 'restore' | 'discard'
|
|
||||||
|
|
||||||
interface CheckpointConfirmationProps {
|
|
||||||
/** Confirmation variant - 'restore' for reverting, 'discard' for edit with checkpoint options */
|
|
||||||
variant: CheckpointConfirmationVariant
|
|
||||||
/** Whether an action is currently processing */
|
|
||||||
isProcessing: boolean
|
|
||||||
/** Callback when cancel is clicked */
|
|
||||||
onCancel: () => void
|
|
||||||
/** Callback when revert is clicked */
|
|
||||||
onRevert: () => void
|
|
||||||
/** Callback when continue is clicked (only for 'discard' variant) */
|
|
||||||
onContinue?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Inline confirmation for checkpoint operations
|
|
||||||
* Supports two variants:
|
|
||||||
* - 'restore': Simple revert confirmation with warning
|
|
||||||
* - 'discard': Edit with checkpoint options (revert or continue without revert)
|
|
||||||
*/
|
|
||||||
export function CheckpointConfirmation({
|
|
||||||
variant,
|
|
||||||
isProcessing,
|
|
||||||
onCancel,
|
|
||||||
onRevert,
|
|
||||||
onContinue,
|
|
||||||
}: CheckpointConfirmationProps) {
|
|
||||||
const isRestoreVariant = variant === 'restore'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='mt-[8px] rounded-[4px] border border-[var(--border)] bg-[var(--surface-4)] p-[10px]'>
|
|
||||||
<p className='mb-[8px] text-[12px] text-[var(--text-primary)]'>
|
|
||||||
{isRestoreVariant ? (
|
|
||||||
<>
|
|
||||||
Revert to checkpoint? This will restore your workflow to the state saved at this
|
|
||||||
checkpoint.{' '}
|
|
||||||
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
'Continue from a previous message?'
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
<div className='flex gap-[8px]'>
|
|
||||||
<Button
|
|
||||||
onClick={onCancel}
|
|
||||||
variant='active'
|
|
||||||
size='sm'
|
|
||||||
className='flex-1'
|
|
||||||
disabled={isProcessing}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={onRevert}
|
|
||||||
variant='destructive'
|
|
||||||
size='sm'
|
|
||||||
className='flex-1'
|
|
||||||
disabled={isProcessing}
|
|
||||||
>
|
|
||||||
{isProcessing ? 'Reverting...' : 'Revert'}
|
|
||||||
</Button>
|
|
||||||
{!isRestoreVariant && onContinue && (
|
|
||||||
<Button
|
|
||||||
onClick={onContinue}
|
|
||||||
variant='tertiary'
|
|
||||||
size='sm'
|
|
||||||
className='flex-1'
|
|
||||||
disabled={isProcessing}
|
|
||||||
>
|
|
||||||
Continue
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from './checkpoint-confirmation'
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from './file-display'
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
export * from './checkpoint-confirmation'
|
|
||||||
export * from './file-display'
|
export * from './file-display'
|
||||||
export { CopilotMarkdownRenderer } from './markdown-renderer'
|
export { default as CopilotMarkdownRenderer } from './markdown-renderer'
|
||||||
export * from './smooth-streaming'
|
export * from './smooth-streaming'
|
||||||
export * from './thinking-block'
|
export * from './thinking-block'
|
||||||
export * from './usage-limit-actions'
|
export * from './usage-limit-actions'
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export { default as CopilotMarkdownRenderer } from './markdown-renderer'
|
|
||||||
@@ -1,17 +1,27 @@
|
|||||||
import { memo, useEffect, useRef, useState } from 'react'
|
import { memo, useEffect, useRef, useState } from 'react'
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
import { CopilotMarkdownRenderer } from '../markdown-renderer'
|
import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
|
||||||
|
|
||||||
/** Character animation delay in milliseconds */
|
/**
|
||||||
|
* Character animation delay in milliseconds
|
||||||
|
*/
|
||||||
const CHARACTER_DELAY = 3
|
const CHARACTER_DELAY = 3
|
||||||
|
|
||||||
/** Props for the StreamingIndicator component */
|
/**
|
||||||
|
* Props for the StreamingIndicator component
|
||||||
|
*/
|
||||||
interface StreamingIndicatorProps {
|
interface StreamingIndicatorProps {
|
||||||
/** Optional class name for layout adjustments */
|
/** Optional class name for layout adjustments */
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Shows animated dots during message streaming when no content has arrived */
|
/**
|
||||||
|
* StreamingIndicator shows animated dots during message streaming
|
||||||
|
* Used as a standalone indicator when no content has arrived yet
|
||||||
|
*
|
||||||
|
* @param props - Component props
|
||||||
|
* @returns Animated loading indicator
|
||||||
|
*/
|
||||||
export const StreamingIndicator = memo(({ className }: StreamingIndicatorProps) => (
|
export const StreamingIndicator = memo(({ className }: StreamingIndicatorProps) => (
|
||||||
<div className={cn('flex h-[1.25rem] items-center text-muted-foreground', className)}>
|
<div className={cn('flex h-[1.25rem] items-center text-muted-foreground', className)}>
|
||||||
<div className='flex space-x-0.5'>
|
<div className='flex space-x-0.5'>
|
||||||
@@ -24,7 +34,9 @@ export const StreamingIndicator = memo(({ className }: StreamingIndicatorProps)
|
|||||||
|
|
||||||
StreamingIndicator.displayName = 'StreamingIndicator'
|
StreamingIndicator.displayName = 'StreamingIndicator'
|
||||||
|
|
||||||
/** Props for the SmoothStreamingText component */
|
/**
|
||||||
|
* Props for the SmoothStreamingText component
|
||||||
|
*/
|
||||||
interface SmoothStreamingTextProps {
|
interface SmoothStreamingTextProps {
|
||||||
/** Content to display with streaming animation */
|
/** Content to display with streaming animation */
|
||||||
content: string
|
content: string
|
||||||
@@ -32,12 +44,20 @@ interface SmoothStreamingTextProps {
|
|||||||
isStreaming: boolean
|
isStreaming: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Displays text with character-by-character animation for smooth streaming */
|
/**
|
||||||
|
* SmoothStreamingText component displays text with character-by-character animation
|
||||||
|
* Creates a smooth streaming effect for AI responses
|
||||||
|
*
|
||||||
|
* @param props - Component props
|
||||||
|
* @returns Streaming text with smooth animation
|
||||||
|
*/
|
||||||
export const SmoothStreamingText = memo(
|
export const SmoothStreamingText = memo(
|
||||||
({ content, isStreaming }: SmoothStreamingTextProps) => {
|
({ content, isStreaming }: SmoothStreamingTextProps) => {
|
||||||
|
// Initialize with full content when not streaming to avoid flash on page load
|
||||||
const [displayedContent, setDisplayedContent] = useState(() => (isStreaming ? '' : content))
|
const [displayedContent, setDisplayedContent] = useState(() => (isStreaming ? '' : content))
|
||||||
const contentRef = useRef(content)
|
const contentRef = useRef(content)
|
||||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
|
// Initialize index based on streaming state
|
||||||
const indexRef = useRef(isStreaming ? 0 : content.length)
|
const indexRef = useRef(isStreaming ? 0 : content.length)
|
||||||
const isAnimatingRef = useRef(false)
|
const isAnimatingRef = useRef(false)
|
||||||
|
|
||||||
@@ -75,6 +95,7 @@ export const SmoothStreamingText = memo(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Streaming ended - show full content immediately
|
||||||
if (timeoutRef.current) {
|
if (timeoutRef.current) {
|
||||||
clearTimeout(timeoutRef.current)
|
clearTimeout(timeoutRef.current)
|
||||||
}
|
}
|
||||||
@@ -98,6 +119,7 @@ export const SmoothStreamingText = memo(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
(prevProps, nextProps) => {
|
(prevProps, nextProps) => {
|
||||||
|
// Prevent re-renders during streaming unless content actually changed
|
||||||
return (
|
return (
|
||||||
prevProps.content === nextProps.content && prevProps.isStreaming === nextProps.isStreaming
|
prevProps.content === nextProps.content && prevProps.isStreaming === nextProps.isStreaming
|
||||||
)
|
)
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from './smooth-streaming'
|
|
||||||
@@ -3,45 +3,66 @@
|
|||||||
import { memo, useEffect, useMemo, useRef, useState } from 'react'
|
import { memo, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { ChevronUp } from 'lucide-react'
|
import { ChevronUp } from 'lucide-react'
|
||||||
import { CopilotMarkdownRenderer } from '../markdown-renderer'
|
import CopilotMarkdownRenderer from './markdown-renderer'
|
||||||
|
|
||||||
/** Removes thinking tags (raw or escaped) and special tags from streamed content */
|
/**
|
||||||
|
* Removes thinking tags (raw or escaped) from streamed content.
|
||||||
|
*/
|
||||||
function stripThinkingTags(text: string): string {
|
function stripThinkingTags(text: string): string {
|
||||||
return text
|
return text
|
||||||
.replace(/<\/?thinking[^>]*>/gi, '')
|
.replace(/<\/?thinking[^>]*>/gi, '')
|
||||||
.replace(/<\/?thinking[^&]*>/gi, '')
|
.replace(/<\/?thinking[^&]*>/gi, '')
|
||||||
.replace(/<options>[\s\S]*?<\/options>/gi, '')
|
|
||||||
.replace(/<options>[\s\S]*$/gi, '')
|
|
||||||
.replace(/<plan>[\s\S]*?<\/plan>/gi, '')
|
|
||||||
.replace(/<plan>[\s\S]*$/gi, '')
|
|
||||||
.trim()
|
.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Interval for auto-scroll during streaming (ms) */
|
/**
|
||||||
|
* Max height for thinking content before internal scrolling kicks in
|
||||||
|
*/
|
||||||
|
const THINKING_MAX_HEIGHT = 150
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Height threshold before gradient fade kicks in
|
||||||
|
*/
|
||||||
|
const GRADIENT_THRESHOLD = 100
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interval for auto-scroll during streaming (ms)
|
||||||
|
*/
|
||||||
const SCROLL_INTERVAL = 50
|
const SCROLL_INTERVAL = 50
|
||||||
|
|
||||||
/** Timer update interval in milliseconds */
|
/**
|
||||||
|
* Timer update interval in milliseconds
|
||||||
|
*/
|
||||||
const TIMER_UPDATE_INTERVAL = 100
|
const TIMER_UPDATE_INTERVAL = 100
|
||||||
|
|
||||||
/** Thinking text streaming delay - faster than main text */
|
/**
|
||||||
|
* Thinking text streaming - much faster than main text
|
||||||
|
* Essentially instant with minimal delay
|
||||||
|
*/
|
||||||
const THINKING_DELAY = 0.5
|
const THINKING_DELAY = 0.5
|
||||||
const THINKING_CHARS_PER_FRAME = 3
|
const THINKING_CHARS_PER_FRAME = 3
|
||||||
|
|
||||||
/** Props for the SmoothThinkingText component */
|
/**
|
||||||
|
* Props for the SmoothThinkingText component
|
||||||
|
*/
|
||||||
interface SmoothThinkingTextProps {
|
interface SmoothThinkingTextProps {
|
||||||
content: string
|
content: string
|
||||||
isStreaming: boolean
|
isStreaming: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders thinking content with fast streaming animation.
|
* SmoothThinkingText renders thinking content with fast streaming animation
|
||||||
|
* Uses gradient fade at top when content is tall enough
|
||||||
*/
|
*/
|
||||||
const SmoothThinkingText = memo(
|
const SmoothThinkingText = memo(
|
||||||
({ content, isStreaming }: SmoothThinkingTextProps) => {
|
({ content, isStreaming }: SmoothThinkingTextProps) => {
|
||||||
|
// Initialize with full content when not streaming to avoid flash on page load
|
||||||
const [displayedContent, setDisplayedContent] = useState(() => (isStreaming ? '' : content))
|
const [displayedContent, setDisplayedContent] = useState(() => (isStreaming ? '' : content))
|
||||||
|
const [showGradient, setShowGradient] = useState(false)
|
||||||
const contentRef = useRef(content)
|
const contentRef = useRef(content)
|
||||||
const textRef = useRef<HTMLDivElement>(null)
|
const textRef = useRef<HTMLDivElement>(null)
|
||||||
const rafRef = useRef<number | null>(null)
|
const rafRef = useRef<number | null>(null)
|
||||||
|
// Initialize index based on streaming state
|
||||||
const indexRef = useRef(isStreaming ? 0 : content.length)
|
const indexRef = useRef(isStreaming ? 0 : content.length)
|
||||||
const lastFrameTimeRef = useRef<number>(0)
|
const lastFrameTimeRef = useRef<number>(0)
|
||||||
const isAnimatingRef = useRef(false)
|
const isAnimatingRef = useRef(false)
|
||||||
@@ -67,6 +88,7 @@ const SmoothThinkingText = memo(
|
|||||||
|
|
||||||
if (elapsed >= THINKING_DELAY) {
|
if (elapsed >= THINKING_DELAY) {
|
||||||
if (currentIndex < currentContent.length) {
|
if (currentIndex < currentContent.length) {
|
||||||
|
// Reveal multiple characters per frame for faster streaming
|
||||||
const newIndex = Math.min(
|
const newIndex = Math.min(
|
||||||
currentIndex + THINKING_CHARS_PER_FRAME,
|
currentIndex + THINKING_CHARS_PER_FRAME,
|
||||||
currentContent.length
|
currentContent.length
|
||||||
@@ -88,6 +110,7 @@ const SmoothThinkingText = memo(
|
|||||||
rafRef.current = requestAnimationFrame(animateText)
|
rafRef.current = requestAnimationFrame(animateText)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Streaming ended - show full content immediately
|
||||||
if (rafRef.current) {
|
if (rafRef.current) {
|
||||||
cancelAnimationFrame(rafRef.current)
|
cancelAnimationFrame(rafRef.current)
|
||||||
}
|
}
|
||||||
@@ -104,10 +127,30 @@ const SmoothThinkingText = memo(
|
|||||||
}
|
}
|
||||||
}, [content, isStreaming])
|
}, [content, isStreaming])
|
||||||
|
|
||||||
|
// Check if content height exceeds threshold for gradient
|
||||||
|
useEffect(() => {
|
||||||
|
if (textRef.current && isStreaming) {
|
||||||
|
const height = textRef.current.scrollHeight
|
||||||
|
setShowGradient(height > GRADIENT_THRESHOLD)
|
||||||
|
} else {
|
||||||
|
setShowGradient(false)
|
||||||
|
}
|
||||||
|
}, [displayedContent, isStreaming])
|
||||||
|
|
||||||
|
// Apply vertical gradient fade at the top only when content is tall enough
|
||||||
|
const gradientStyle =
|
||||||
|
isStreaming && showGradient
|
||||||
|
? {
|
||||||
|
maskImage: 'linear-gradient(to bottom, transparent 0%, black 30%, black 100%)',
|
||||||
|
WebkitMaskImage: 'linear-gradient(to bottom, transparent 0%, black 30%, black 100%)',
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={textRef}
|
ref={textRef}
|
||||||
className='[&_*]:!text-[var(--text-muted)] [&_*]:!text-[12px] [&_*]:!leading-[1.4] [&_p]:!m-0 [&_p]:!mb-1 [&_h1]:!text-[12px] [&_h1]:!font-semibold [&_h1]:!m-0 [&_h1]:!mb-1 [&_h2]:!text-[12px] [&_h2]:!font-semibold [&_h2]:!m-0 [&_h2]:!mb-1 [&_h3]:!text-[12px] [&_h3]:!font-semibold [&_h3]:!m-0 [&_h3]:!mb-1 [&_code]:!text-[11px] [&_ul]:!pl-5 [&_ul]:!my-1 [&_ol]:!pl-6 [&_ol]:!my-1 [&_li]:!my-0.5 [&_li]:!py-0 font-season text-[12px] text-[var(--text-muted)]'
|
className='[&_*]:!text-[var(--text-muted)] [&_*]:!text-[12px] [&_*]:!leading-[1.4] [&_p]:!m-0 [&_p]:!mb-1 [&_h1]:!text-[12px] [&_h1]:!font-semibold [&_h1]:!m-0 [&_h1]:!mb-1 [&_h2]:!text-[12px] [&_h2]:!font-semibold [&_h2]:!m-0 [&_h2]:!mb-1 [&_h3]:!text-[12px] [&_h3]:!font-semibold [&_h3]:!m-0 [&_h3]:!mb-1 [&_code]:!text-[11px] [&_ul]:!pl-5 [&_ul]:!my-1 [&_ol]:!pl-6 [&_ol]:!my-1 [&_li]:!my-0.5 [&_li]:!py-0 font-season text-[12px] text-[var(--text-muted)]'
|
||||||
|
style={gradientStyle}
|
||||||
>
|
>
|
||||||
<CopilotMarkdownRenderer content={displayedContent} />
|
<CopilotMarkdownRenderer content={displayedContent} />
|
||||||
</div>
|
</div>
|
||||||
@@ -122,7 +165,9 @@ const SmoothThinkingText = memo(
|
|||||||
|
|
||||||
SmoothThinkingText.displayName = 'SmoothThinkingText'
|
SmoothThinkingText.displayName = 'SmoothThinkingText'
|
||||||
|
|
||||||
/** Props for the ThinkingBlock component */
|
/**
|
||||||
|
* Props for the ThinkingBlock component
|
||||||
|
*/
|
||||||
interface ThinkingBlockProps {
|
interface ThinkingBlockProps {
|
||||||
/** Content of the thinking block */
|
/** Content of the thinking block */
|
||||||
content: string
|
content: string
|
||||||
@@ -137,8 +182,13 @@ interface ThinkingBlockProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Displays AI reasoning/thinking process with collapsible content and duration timer.
|
* ThinkingBlock component displays AI reasoning/thinking process
|
||||||
* Auto-expands during streaming and collapses when complete.
|
* Shows collapsible content with duration timer
|
||||||
|
* Auto-expands during streaming and collapses when complete
|
||||||
|
* Auto-collapses when a tool call or other content comes in after it
|
||||||
|
*
|
||||||
|
* @param props - Component props
|
||||||
|
* @returns Thinking block with expandable content and timer
|
||||||
*/
|
*/
|
||||||
export function ThinkingBlock({
|
export function ThinkingBlock({
|
||||||
content,
|
content,
|
||||||
@@ -147,6 +197,7 @@ export function ThinkingBlock({
|
|||||||
label = 'Thought',
|
label = 'Thought',
|
||||||
hasSpecialTags = false,
|
hasSpecialTags = false,
|
||||||
}: ThinkingBlockProps) {
|
}: ThinkingBlockProps) {
|
||||||
|
// Strip thinking tags from content on render to handle persisted messages
|
||||||
const cleanContent = useMemo(() => stripThinkingTags(content || ''), [content])
|
const cleanContent = useMemo(() => stripThinkingTags(content || ''), [content])
|
||||||
|
|
||||||
const [isExpanded, setIsExpanded] = useState(false)
|
const [isExpanded, setIsExpanded] = useState(false)
|
||||||
@@ -158,8 +209,12 @@ export function ThinkingBlock({
|
|||||||
const lastScrollTopRef = useRef(0)
|
const lastScrollTopRef = useRef(0)
|
||||||
const programmaticScrollRef = useRef(false)
|
const programmaticScrollRef = useRef(false)
|
||||||
|
|
||||||
/** Auto-expands during streaming, auto-collapses when streaming ends or following content arrives */
|
/**
|
||||||
|
* Auto-expands block when streaming with content
|
||||||
|
* Auto-collapses when streaming ends OR when following content arrives
|
||||||
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Collapse if streaming ended, there's following content, or special tags arrived
|
||||||
if (!isStreaming || hasFollowingContent || hasSpecialTags) {
|
if (!isStreaming || hasFollowingContent || hasSpecialTags) {
|
||||||
setIsExpanded(false)
|
setIsExpanded(false)
|
||||||
userCollapsedRef.current = false
|
userCollapsedRef.current = false
|
||||||
@@ -172,6 +227,7 @@ export function ThinkingBlock({
|
|||||||
}
|
}
|
||||||
}, [isStreaming, cleanContent, hasFollowingContent, hasSpecialTags])
|
}, [isStreaming, cleanContent, hasFollowingContent, hasSpecialTags])
|
||||||
|
|
||||||
|
// Reset start time when streaming begins
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isStreaming && !hasFollowingContent) {
|
if (isStreaming && !hasFollowingContent) {
|
||||||
startTimeRef.current = Date.now()
|
startTimeRef.current = Date.now()
|
||||||
@@ -180,7 +236,9 @@ export function ThinkingBlock({
|
|||||||
}
|
}
|
||||||
}, [isStreaming, hasFollowingContent])
|
}, [isStreaming, hasFollowingContent])
|
||||||
|
|
||||||
|
// Update duration timer during streaming (stop when following content arrives)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Stop timer if not streaming or if there's following content (thinking is done)
|
||||||
if (!isStreaming || hasFollowingContent) return
|
if (!isStreaming || hasFollowingContent) return
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
@@ -190,6 +248,7 @@ export function ThinkingBlock({
|
|||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
}, [isStreaming, hasFollowingContent])
|
}, [isStreaming, hasFollowingContent])
|
||||||
|
|
||||||
|
// Handle scroll events to detect user scrolling away
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = scrollContainerRef.current
|
const container = scrollContainerRef.current
|
||||||
if (!container || !isExpanded) return
|
if (!container || !isExpanded) return
|
||||||
@@ -208,6 +267,7 @@ export function ThinkingBlock({
|
|||||||
setUserHasScrolledAway(true)
|
setUserHasScrolledAway(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-stick if user scrolls back to bottom with intent
|
||||||
if (userHasScrolledAway && isNearBottom && delta > 10) {
|
if (userHasScrolledAway && isNearBottom && delta > 10) {
|
||||||
setUserHasScrolledAway(false)
|
setUserHasScrolledAway(false)
|
||||||
}
|
}
|
||||||
@@ -221,6 +281,7 @@ export function ThinkingBlock({
|
|||||||
return () => container.removeEventListener('scroll', handleScroll)
|
return () => container.removeEventListener('scroll', handleScroll)
|
||||||
}, [isExpanded, userHasScrolledAway])
|
}, [isExpanded, userHasScrolledAway])
|
||||||
|
|
||||||
|
// Smart auto-scroll: always scroll to bottom while streaming unless user scrolled away
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isStreaming || !isExpanded || userHasScrolledAway) return
|
if (!isStreaming || !isExpanded || userHasScrolledAway) return
|
||||||
|
|
||||||
@@ -241,16 +302,20 @@ export function ThinkingBlock({
|
|||||||
return () => window.clearInterval(intervalId)
|
return () => window.clearInterval(intervalId)
|
||||||
}, [isStreaming, isExpanded, userHasScrolledAway])
|
}, [isStreaming, isExpanded, userHasScrolledAway])
|
||||||
|
|
||||||
/** Formats duration in milliseconds to seconds (minimum 1s) */
|
/**
|
||||||
|
* Formats duration in milliseconds to seconds
|
||||||
|
* Always shows seconds, rounded to nearest whole second, minimum 1s
|
||||||
|
*/
|
||||||
const formatDuration = (ms: number) => {
|
const formatDuration = (ms: number) => {
|
||||||
const seconds = Math.max(1, Math.round(ms / 1000))
|
const seconds = Math.max(1, Math.round(ms / 1000))
|
||||||
return `${seconds}s`
|
return `${seconds}s`
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasContent = cleanContent.length > 0
|
const hasContent = cleanContent.length > 0
|
||||||
|
// Thinking is "done" when streaming ends OR when there's following content (like a tool call) OR when special tags appear
|
||||||
const isThinkingDone = !isStreaming || hasFollowingContent || hasSpecialTags
|
const isThinkingDone = !isStreaming || hasFollowingContent || hasSpecialTags
|
||||||
const durationText = `${label} for ${formatDuration(duration)}`
|
const durationText = `${label} for ${formatDuration(duration)}`
|
||||||
|
// Convert past tense label to present tense for streaming (e.g., "Thought" → "Thinking")
|
||||||
const getStreamingLabel = (lbl: string) => {
|
const getStreamingLabel = (lbl: string) => {
|
||||||
if (lbl === 'Thought') return 'Thinking'
|
if (lbl === 'Thought') return 'Thinking'
|
||||||
if (lbl.endsWith('ed')) return `${lbl.slice(0, -2)}ing`
|
if (lbl.endsWith('ed')) return `${lbl.slice(0, -2)}ing`
|
||||||
@@ -258,9 +323,11 @@ export function ThinkingBlock({
|
|||||||
}
|
}
|
||||||
const streamingLabel = getStreamingLabel(label)
|
const streamingLabel = getStreamingLabel(label)
|
||||||
|
|
||||||
|
// During streaming: show header with shimmer effect + expanded content
|
||||||
if (!isThinkingDone) {
|
if (!isThinkingDone) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
{/* Define shimmer keyframes */}
|
||||||
<style>{`
|
<style>{`
|
||||||
@keyframes thinking-shimmer {
|
@keyframes thinking-shimmer {
|
||||||
0% { background-position: 150% 0; }
|
0% { background-position: 150% 0; }
|
||||||
@@ -329,6 +396,7 @@ export function ThinkingBlock({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// After done: show collapsible header with duration
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
@@ -358,6 +426,7 @@ export function ThinkingBlock({
|
|||||||
isExpanded ? 'mt-1.5 max-h-[150px] opacity-100' : 'max-h-0 opacity-0'
|
isExpanded ? 'mt-1.5 max-h-[150px] opacity-100' : 'max-h-0 opacity-0'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{/* Completed thinking text - dimmed with markdown */}
|
||||||
<div className='[&_*]:!text-[var(--text-muted)] [&_*]:!text-[12px] [&_*]:!leading-[1.4] [&_p]:!m-0 [&_p]:!mb-1 [&_h1]:!text-[12px] [&_h1]:!font-semibold [&_h1]:!m-0 [&_h1]:!mb-1 [&_h2]:!text-[12px] [&_h2]:!font-semibold [&_h2]:!m-0 [&_h2]:!mb-1 [&_h3]:!text-[12px] [&_h3]:!font-semibold [&_h3]:!m-0 [&_h3]:!mb-1 [&_code]:!text-[11px] [&_ul]:!pl-5 [&_ul]:!my-1 [&_ol]:!pl-6 [&_ol]:!my-1 [&_li]:!my-0.5 [&_li]:!py-0 font-season text-[12px] text-[var(--text-muted)]'>
|
<div className='[&_*]:!text-[var(--text-muted)] [&_*]:!text-[12px] [&_*]:!leading-[1.4] [&_p]:!m-0 [&_p]:!mb-1 [&_h1]:!text-[12px] [&_h1]:!font-semibold [&_h1]:!m-0 [&_h1]:!mb-1 [&_h2]:!text-[12px] [&_h2]:!font-semibold [&_h2]:!m-0 [&_h2]:!mb-1 [&_h3]:!text-[12px] [&_h3]:!font-semibold [&_h3]:!m-0 [&_h3]:!mb-1 [&_code]:!text-[11px] [&_ul]:!pl-5 [&_ul]:!my-1 [&_ol]:!pl-6 [&_ol]:!my-1 [&_li]:!my-0.5 [&_li]:!py-0 font-season text-[12px] text-[var(--text-muted)]'>
|
||||||
<CopilotMarkdownRenderer content={cleanContent} />
|
<CopilotMarkdownRenderer content={cleanContent} />
|
||||||
</div>
|
</div>
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from './thinking-block'
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from './usage-limit-actions'
|
|
||||||
@@ -9,20 +9,18 @@ import {
|
|||||||
ToolCall,
|
ToolCall,
|
||||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components'
|
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components'
|
||||||
import {
|
import {
|
||||||
CheckpointConfirmation,
|
|
||||||
FileAttachmentDisplay,
|
FileAttachmentDisplay,
|
||||||
SmoothStreamingText,
|
SmoothStreamingText,
|
||||||
StreamingIndicator,
|
StreamingIndicator,
|
||||||
ThinkingBlock,
|
ThinkingBlock,
|
||||||
UsageLimitActions,
|
UsageLimitActions,
|
||||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components'
|
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components'
|
||||||
import { CopilotMarkdownRenderer } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
|
import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
|
||||||
import {
|
import {
|
||||||
useCheckpointManagement,
|
useCheckpointManagement,
|
||||||
useMessageEditing,
|
useMessageEditing,
|
||||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/hooks'
|
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/hooks'
|
||||||
import { UserInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input'
|
import { UserInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/user-input'
|
||||||
import { buildMentionHighlightNodes } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
|
|
||||||
import type { CopilotMessage as CopilotMessageType } from '@/stores/panel'
|
import type { CopilotMessage as CopilotMessageType } from '@/stores/panel'
|
||||||
import { useCopilotStore } from '@/stores/panel'
|
import { useCopilotStore } from '@/stores/panel'
|
||||||
|
|
||||||
@@ -70,6 +68,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
const isUser = message.role === 'user'
|
const isUser = message.role === 'user'
|
||||||
const isAssistant = message.role === 'assistant'
|
const isAssistant = message.role === 'assistant'
|
||||||
|
|
||||||
|
// Store state
|
||||||
const {
|
const {
|
||||||
messageCheckpoints: allMessageCheckpoints,
|
messageCheckpoints: allMessageCheckpoints,
|
||||||
messages,
|
messages,
|
||||||
@@ -80,18 +79,23 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
isAborting,
|
isAborting,
|
||||||
} = useCopilotStore()
|
} = useCopilotStore()
|
||||||
|
|
||||||
|
// Get checkpoints for this message if it's a user message
|
||||||
const messageCheckpoints = isUser ? allMessageCheckpoints[message.id] || [] : []
|
const messageCheckpoints = isUser ? allMessageCheckpoints[message.id] || [] : []
|
||||||
const hasCheckpoints = messageCheckpoints.length > 0 && messageCheckpoints.some((cp) => cp?.id)
|
const hasCheckpoints = messageCheckpoints.length > 0 && messageCheckpoints.some((cp) => cp?.id)
|
||||||
|
|
||||||
|
// Check if this is the last user message (for showing abort button)
|
||||||
const isLastUserMessage = useMemo(() => {
|
const isLastUserMessage = useMemo(() => {
|
||||||
if (!isUser) return false
|
if (!isUser) return false
|
||||||
const userMessages = messages.filter((m) => m.role === 'user')
|
const userMessages = messages.filter((m) => m.role === 'user')
|
||||||
return userMessages.length > 0 && userMessages[userMessages.length - 1]?.id === message.id
|
return userMessages.length > 0 && userMessages[userMessages.length - 1]?.id === message.id
|
||||||
}, [isUser, messages, message.id])
|
}, [isUser, messages, message.id])
|
||||||
|
|
||||||
|
// UI state
|
||||||
const [isHoveringMessage, setIsHoveringMessage] = useState(false)
|
const [isHoveringMessage, setIsHoveringMessage] = useState(false)
|
||||||
|
|
||||||
const cancelEditRef = useRef<(() => void) | null>(null)
|
const cancelEditRef = useRef<(() => void) | null>(null)
|
||||||
|
|
||||||
|
// Checkpoint management hook
|
||||||
const {
|
const {
|
||||||
showRestoreConfirmation,
|
showRestoreConfirmation,
|
||||||
showCheckpointDiscardModal,
|
showCheckpointDiscardModal,
|
||||||
@@ -114,6 +118,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
() => cancelEditRef.current?.()
|
() => cancelEditRef.current?.()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Message editing hook
|
||||||
const {
|
const {
|
||||||
isEditMode,
|
isEditMode,
|
||||||
isExpanded,
|
isExpanded,
|
||||||
@@ -142,20 +147,27 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
|
|
||||||
cancelEditRef.current = handleCancelEdit
|
cancelEditRef.current = handleCancelEdit
|
||||||
|
|
||||||
|
// Get clean text content with double newline parsing
|
||||||
const cleanTextContent = useMemo(() => {
|
const cleanTextContent = useMemo(() => {
|
||||||
if (!message.content) return ''
|
if (!message.content) return ''
|
||||||
|
|
||||||
|
// Parse out excessive newlines (more than 2 consecutive newlines)
|
||||||
return message.content.replace(/\n{3,}/g, '\n\n')
|
return message.content.replace(/\n{3,}/g, '\n\n')
|
||||||
}, [message.content])
|
}, [message.content])
|
||||||
|
|
||||||
|
// Parse special tags from message content (options, plan)
|
||||||
|
// Parse during streaming to show options/plan as they stream in
|
||||||
const parsedTags = useMemo(() => {
|
const parsedTags = useMemo(() => {
|
||||||
if (isUser) return null
|
if (isUser) return null
|
||||||
|
|
||||||
|
// Try message.content first
|
||||||
if (message.content) {
|
if (message.content) {
|
||||||
const parsed = parseSpecialTags(message.content)
|
const parsed = parseSpecialTags(message.content)
|
||||||
if (parsed.options || parsed.plan) return parsed
|
if (parsed.options || parsed.plan) return parsed
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.contentBlocks && message.contentBlocks.length > 0) {
|
// During streaming, check content blocks for options/plan
|
||||||
|
if (isStreaming && message.contentBlocks && message.contentBlocks.length > 0) {
|
||||||
for (const block of message.contentBlocks) {
|
for (const block of message.contentBlocks) {
|
||||||
if (block.type === 'text' && block.content) {
|
if (block.type === 'text' && block.content) {
|
||||||
const parsed = parseSpecialTags(block.content)
|
const parsed = parseSpecialTags(block.content)
|
||||||
@@ -164,42 +176,23 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return message.content ? parseSpecialTags(message.content) : null
|
||||||
}, [message.content, message.contentBlocks, isUser])
|
}, [message.content, message.contentBlocks, isUser, isStreaming])
|
||||||
|
|
||||||
const selectedOptionKey = useMemo(() => {
|
|
||||||
if (!parsedTags?.options || isStreaming) return null
|
|
||||||
|
|
||||||
const currentIndex = messages.findIndex((m) => m.id === message.id)
|
|
||||||
if (currentIndex === -1 || currentIndex >= messages.length - 1) return null
|
|
||||||
|
|
||||||
const nextMessage = messages[currentIndex + 1]
|
|
||||||
if (!nextMessage || nextMessage.role !== 'user') return null
|
|
||||||
|
|
||||||
const nextContent = nextMessage.content?.trim()
|
|
||||||
if (!nextContent) return null
|
|
||||||
|
|
||||||
for (const [key, option] of Object.entries(parsedTags.options)) {
|
|
||||||
const optionTitle = typeof option === 'string' ? option : option.title
|
|
||||||
if (nextContent === optionTitle) {
|
|
||||||
return key
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
}, [parsedTags?.options, messages, message.id, isStreaming])
|
|
||||||
|
|
||||||
|
// Get sendMessage from store for continuation actions
|
||||||
const sendMessage = useCopilotStore((s) => s.sendMessage)
|
const sendMessage = useCopilotStore((s) => s.sendMessage)
|
||||||
|
|
||||||
|
// Handler for option selection
|
||||||
const handleOptionSelect = useCallback(
|
const handleOptionSelect = useCallback(
|
||||||
(_optionKey: string, optionText: string) => {
|
(_optionKey: string, optionText: string) => {
|
||||||
|
// Send the option text as a message
|
||||||
sendMessage(optionText)
|
sendMessage(optionText)
|
||||||
},
|
},
|
||||||
[sendMessage]
|
[sendMessage]
|
||||||
)
|
)
|
||||||
|
|
||||||
const isActivelyStreaming = isLastMessage && isStreaming
|
// Memoize content blocks to avoid re-rendering unchanged blocks
|
||||||
|
// No entrance animations to prevent layout shift
|
||||||
const memoizedContentBlocks = useMemo(() => {
|
const memoizedContentBlocks = useMemo(() => {
|
||||||
if (!message.contentBlocks || message.contentBlocks.length === 0) {
|
if (!message.contentBlocks || message.contentBlocks.length === 0) {
|
||||||
return null
|
return null
|
||||||
@@ -209,21 +202,21 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
if (block.type === 'text') {
|
if (block.type === 'text') {
|
||||||
const isLastTextBlock =
|
const isLastTextBlock =
|
||||||
index === message.contentBlocks!.length - 1 && block.type === 'text'
|
index === message.contentBlocks!.length - 1 && block.type === 'text'
|
||||||
|
// Always strip special tags from display (they're rendered separately as options/plan)
|
||||||
const parsed = parseSpecialTags(block.content)
|
const parsed = parseSpecialTags(block.content)
|
||||||
const cleanBlockContent = parsed.cleanContent.replace(/\n{3,}/g, '\n\n')
|
const cleanBlockContent = parsed.cleanContent.replace(/\n{3,}/g, '\n\n')
|
||||||
|
|
||||||
|
// Skip if no content after stripping tags
|
||||||
if (!cleanBlockContent.trim()) return null
|
if (!cleanBlockContent.trim()) return null
|
||||||
|
|
||||||
const shouldUseSmoothing = isActivelyStreaming && isLastTextBlock
|
// Use smooth streaming for the last text block if we're streaming
|
||||||
|
const shouldUseSmoothing = isStreaming && isLastTextBlock
|
||||||
const blockKey = `text-${index}-${block.timestamp || index}`
|
const blockKey = `text-${index}-${block.timestamp || index}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={blockKey} className='w-full max-w-full'>
|
<div key={blockKey} className='w-full max-w-full'>
|
||||||
{shouldUseSmoothing ? (
|
{shouldUseSmoothing ? (
|
||||||
<SmoothStreamingText
|
<SmoothStreamingText content={cleanBlockContent} isStreaming={isStreaming} />
|
||||||
content={cleanBlockContent}
|
|
||||||
isStreaming={isActivelyStreaming}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<CopilotMarkdownRenderer content={cleanBlockContent} />
|
<CopilotMarkdownRenderer content={cleanBlockContent} />
|
||||||
)}
|
)}
|
||||||
@@ -231,7 +224,9 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (block.type === 'thinking') {
|
if (block.type === 'thinking') {
|
||||||
|
// Check if there are any blocks after this one (tool calls, text, etc.)
|
||||||
const hasFollowingContent = index < message.contentBlocks!.length - 1
|
const hasFollowingContent = index < message.contentBlocks!.length - 1
|
||||||
|
// Check if special tags (options, plan) are present - should also close thinking
|
||||||
const hasSpecialTags = !!(parsedTags?.options || parsedTags?.plan)
|
const hasSpecialTags = !!(parsedTags?.options || parsedTags?.plan)
|
||||||
const blockKey = `thinking-${index}-${block.timestamp || index}`
|
const blockKey = `thinking-${index}-${block.timestamp || index}`
|
||||||
|
|
||||||
@@ -239,7 +234,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
<div key={blockKey} className='w-full'>
|
<div key={blockKey} className='w-full'>
|
||||||
<ThinkingBlock
|
<ThinkingBlock
|
||||||
content={block.content}
|
content={block.content}
|
||||||
isStreaming={isActivelyStreaming}
|
isStreaming={isStreaming}
|
||||||
hasFollowingContent={hasFollowingContent}
|
hasFollowingContent={hasFollowingContent}
|
||||||
hasSpecialTags={hasSpecialTags}
|
hasSpecialTags={hasSpecialTags}
|
||||||
/>
|
/>
|
||||||
@@ -251,22 +246,18 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={blockKey}>
|
<div key={blockKey}>
|
||||||
<ToolCall
|
<ToolCall toolCallId={block.toolCall.id} toolCall={block.toolCall} />
|
||||||
toolCallId={block.toolCall.id}
|
|
||||||
toolCall={block.toolCall}
|
|
||||||
isCurrentMessage={isLastMessage}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
}, [message.contentBlocks, isActivelyStreaming, parsedTags, isLastMessage])
|
}, [message.contentBlocks, isStreaming, parsedTags])
|
||||||
|
|
||||||
if (isUser) {
|
if (isUser) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`w-full max-w-full flex-none overflow-hidden transition-opacity duration-200 [max-width:var(--panel-max-width)] ${isDimmed ? 'opacity-40' : 'opacity-100'}`}
|
className={`w-full max-w-full overflow-hidden transition-opacity duration-200 [max-width:var(--panel-max-width)] ${isDimmed ? 'opacity-40' : 'opacity-100'}`}
|
||||||
style={{ '--panel-max-width': `${panelWidth - 16}px` } as React.CSSProperties}
|
style={{ '--panel-max-width': `${panelWidth - 16}px` } as React.CSSProperties}
|
||||||
>
|
>
|
||||||
{isEditMode ? (
|
{isEditMode ? (
|
||||||
@@ -297,15 +288,42 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
initialContexts={message.contexts}
|
initialContexts={message.contexts}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Inline checkpoint confirmation - shown below input in edit mode */}
|
{/* Inline Checkpoint Discard Confirmation - shown below input in edit mode */}
|
||||||
{showCheckpointDiscardModal && (
|
{showCheckpointDiscardModal && (
|
||||||
<CheckpointConfirmation
|
<div className='mt-[8px] rounded-[4px] border border-[var(--border)] bg-[var(--surface-4)] p-[10px]'>
|
||||||
variant='discard'
|
<p className='mb-[8px] text-[12px] text-[var(--text-primary)]'>
|
||||||
isProcessing={isProcessingDiscard}
|
Continue from a previous message?
|
||||||
onCancel={handleCancelCheckpointDiscard}
|
</p>
|
||||||
onRevert={handleContinueAndRevert}
|
<div className='flex gap-[8px]'>
|
||||||
onContinue={handleContinueWithoutRevert}
|
<Button
|
||||||
/>
|
onClick={handleCancelCheckpointDiscard}
|
||||||
|
variant='active'
|
||||||
|
size='sm'
|
||||||
|
className='flex-1'
|
||||||
|
disabled={isProcessingDiscard}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleContinueAndRevert}
|
||||||
|
variant='destructive'
|
||||||
|
size='sm'
|
||||||
|
className='flex-1'
|
||||||
|
disabled={isProcessingDiscard}
|
||||||
|
>
|
||||||
|
{isProcessingDiscard ? 'Reverting...' : 'Revert'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleContinueWithoutRevert}
|
||||||
|
variant='tertiary'
|
||||||
|
size='sm'
|
||||||
|
className='flex-1'
|
||||||
|
disabled={isProcessingDiscard}
|
||||||
|
>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -330,15 +348,46 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
ref={messageContentRef}
|
ref={messageContentRef}
|
||||||
className={`relative whitespace-pre-wrap break-words px-[2px] py-1 font-medium font-sans text-[var(--text-primary)] text-sm leading-[1.25rem] ${isSendingMessage && isLastUserMessage && isHoveringMessage ? 'pr-7' : ''} ${!isExpanded && needsExpansion ? 'max-h-[60px] overflow-hidden' : 'overflow-visible'}`}
|
className={`relative whitespace-pre-wrap break-words px-[2px] py-1 font-medium font-sans text-[var(--text-primary)] text-sm leading-[1.25rem] ${isSendingMessage && isLastUserMessage && isHoveringMessage ? 'pr-7' : ''} ${!isExpanded && needsExpansion ? 'max-h-[60px] overflow-hidden' : 'overflow-visible'}`}
|
||||||
>
|
>
|
||||||
{buildMentionHighlightNodes(
|
{(() => {
|
||||||
message.content || '',
|
const text = message.content || ''
|
||||||
message.contexts || [],
|
const contexts: any[] = Array.isArray((message as any).contexts)
|
||||||
(token, key) => (
|
? ((message as any).contexts as any[])
|
||||||
<span key={key} className='rounded-[4px] bg-[rgba(50,189,126,0.65)] py-[1px]'>
|
: []
|
||||||
{token}
|
|
||||||
</span>
|
// Build tokens with their prefixes (@ for mentions, / for commands)
|
||||||
)
|
const tokens = contexts
|
||||||
)}
|
.filter((c) => c?.kind !== 'current_workflow' && c?.label)
|
||||||
|
.map((c) => {
|
||||||
|
const prefix = c?.kind === 'slash_command' ? '/' : '@'
|
||||||
|
return `${prefix}${c.label}`
|
||||||
|
})
|
||||||
|
if (!tokens.length) return text
|
||||||
|
|
||||||
|
const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
const pattern = new RegExp(`(${tokens.map(escapeRegex).join('|')})`, 'g')
|
||||||
|
|
||||||
|
const nodes: React.ReactNode[] = []
|
||||||
|
let lastIndex = 0
|
||||||
|
let match: RegExpExecArray | null
|
||||||
|
while ((match = pattern.exec(text)) !== null) {
|
||||||
|
const i = match.index
|
||||||
|
const before = text.slice(lastIndex, i)
|
||||||
|
if (before) nodes.push(before)
|
||||||
|
const mention = match[0]
|
||||||
|
nodes.push(
|
||||||
|
<span
|
||||||
|
key={`mention-${i}-${lastIndex}`}
|
||||||
|
className='rounded-[4px] bg-[rgba(50,189,126,0.65)] py-[1px]'
|
||||||
|
>
|
||||||
|
{mention}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
lastIndex = i + mention.length
|
||||||
|
}
|
||||||
|
const tail = text.slice(lastIndex)
|
||||||
|
if (tail) nodes.push(tail)
|
||||||
|
return nodes
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Gradient fade when truncated - applies to entire message box */}
|
{/* Gradient fade when truncated - applies to entire message box */}
|
||||||
@@ -388,30 +437,65 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Inline restore checkpoint confirmation */}
|
{/* Inline Restore Checkpoint Confirmation */}
|
||||||
{showRestoreConfirmation && (
|
{showRestoreConfirmation && (
|
||||||
<CheckpointConfirmation
|
<div className='mt-[8px] rounded-[4px] border border-[var(--border)] bg-[var(--surface-4)] p-[10px]'>
|
||||||
variant='restore'
|
<p className='mb-[8px] text-[12px] text-[var(--text-primary)]'>
|
||||||
isProcessing={isReverting}
|
Revert to checkpoint? This will restore your workflow to the state saved at this
|
||||||
onCancel={handleCancelRevert}
|
checkpoint.{' '}
|
||||||
onRevert={handleConfirmRevert}
|
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
|
||||||
/>
|
</p>
|
||||||
|
<div className='flex gap-[8px]'>
|
||||||
|
<Button
|
||||||
|
onClick={handleCancelRevert}
|
||||||
|
variant='active'
|
||||||
|
size='sm'
|
||||||
|
className='flex-1'
|
||||||
|
disabled={isReverting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleConfirmRevert}
|
||||||
|
variant='destructive'
|
||||||
|
size='sm'
|
||||||
|
className='flex-1'
|
||||||
|
disabled={isReverting}
|
||||||
|
>
|
||||||
|
{isReverting ? 'Reverting...' : 'Revert'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if there's any visible content in the blocks
|
||||||
|
const hasVisibleContent = useMemo(() => {
|
||||||
|
if (!message.contentBlocks || message.contentBlocks.length === 0) return false
|
||||||
|
return message.contentBlocks.some((block) => {
|
||||||
|
if (block.type === 'text') {
|
||||||
|
const parsed = parseSpecialTags(block.content)
|
||||||
|
return parsed.cleanContent.trim().length > 0
|
||||||
|
}
|
||||||
|
return block.type === 'thinking' || block.type === 'tool_call'
|
||||||
|
})
|
||||||
|
}, [message.contentBlocks])
|
||||||
|
|
||||||
if (isAssistant) {
|
if (isAssistant) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`w-full max-w-full flex-none overflow-hidden [max-width:var(--panel-max-width)] ${isDimmed ? 'opacity-40' : 'opacity-100'}`}
|
className={`w-full max-w-full overflow-hidden [max-width:var(--panel-max-width)] ${isDimmed ? 'opacity-40' : 'opacity-100'}`}
|
||||||
style={{ '--panel-max-width': `${panelWidth - 16}px` } as React.CSSProperties}
|
style={{ '--panel-max-width': `${panelWidth - 16}px` } as React.CSSProperties}
|
||||||
>
|
>
|
||||||
<div className='max-w-full space-y-[4px] px-[2px] pb-[4px]'>
|
<div className='max-w-full space-y-1 px-[2px]'>
|
||||||
{/* Content blocks in chronological order */}
|
{/* Content blocks in chronological order */}
|
||||||
{memoizedContentBlocks || (isStreaming && <div className='min-h-0' />)}
|
{memoizedContentBlocks}
|
||||||
|
|
||||||
{isStreaming && <StreamingIndicator />}
|
{isStreaming && (
|
||||||
|
<StreamingIndicator className={!hasVisibleContent ? 'mt-1' : undefined} />
|
||||||
|
)}
|
||||||
|
|
||||||
{message.errorType === 'usage_limit' && (
|
{message.errorType === 'usage_limit' && (
|
||||||
<div className='flex gap-1.5'>
|
<div className='flex gap-1.5'>
|
||||||
@@ -450,7 +534,6 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
isLastMessage && !isStreaming && parsedTags.optionsComplete === true
|
isLastMessage && !isStreaming && parsedTags.optionsComplete === true
|
||||||
}
|
}
|
||||||
streaming={isStreaming || !parsedTags.optionsComplete}
|
streaming={isStreaming || !parsedTags.optionsComplete}
|
||||||
selectedOptionKey={selectedOptionKey}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -461,22 +544,50 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
(prevProps, nextProps) => {
|
(prevProps, nextProps) => {
|
||||||
|
// Custom comparison function for better streaming performance
|
||||||
const prevMessage = prevProps.message
|
const prevMessage = prevProps.message
|
||||||
const nextMessage = nextProps.message
|
const nextMessage = nextProps.message
|
||||||
|
|
||||||
if (prevMessage.id !== nextMessage.id) return false
|
// If message IDs are different, always re-render
|
||||||
if (prevProps.isStreaming !== nextProps.isStreaming) return false
|
if (prevMessage.id !== nextMessage.id) {
|
||||||
if (prevProps.isDimmed !== nextProps.isDimmed) return false
|
return false
|
||||||
if (prevProps.panelWidth !== nextProps.panelWidth) return false
|
}
|
||||||
if (prevProps.checkpointCount !== nextProps.checkpointCount) return false
|
|
||||||
if (prevProps.isLastMessage !== nextProps.isLastMessage) return false
|
|
||||||
|
|
||||||
|
// If streaming state changed, re-render
|
||||||
|
if (prevProps.isStreaming !== nextProps.isStreaming) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// If dimmed state changed, re-render
|
||||||
|
if (prevProps.isDimmed !== nextProps.isDimmed) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// If panel width changed, re-render
|
||||||
|
if (prevProps.panelWidth !== nextProps.panelWidth) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// If checkpoint count changed, re-render
|
||||||
|
if (prevProps.checkpointCount !== nextProps.checkpointCount) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// If isLastMessage changed, re-render (for options visibility)
|
||||||
|
if (prevProps.isLastMessage !== nextProps.isLastMessage) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// For streaming messages, check if content actually changed
|
||||||
if (nextProps.isStreaming) {
|
if (nextProps.isStreaming) {
|
||||||
const prevBlocks = prevMessage.contentBlocks || []
|
const prevBlocks = prevMessage.contentBlocks || []
|
||||||
const nextBlocks = nextMessage.contentBlocks || []
|
const nextBlocks = nextMessage.contentBlocks || []
|
||||||
|
|
||||||
if (prevBlocks.length !== nextBlocks.length) return false
|
if (prevBlocks.length !== nextBlocks.length) {
|
||||||
|
return false // Content blocks changed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: get last block content by type
|
||||||
const getLastBlockContent = (blocks: any[], type: 'text' | 'thinking'): string | null => {
|
const getLastBlockContent = (blocks: any[], type: 'text' | 'thinking'): string | null => {
|
||||||
for (let i = blocks.length - 1; i >= 0; i--) {
|
for (let i = blocks.length - 1; i >= 0; i--) {
|
||||||
const block = blocks[i]
|
const block = blocks[i]
|
||||||
@@ -487,6 +598,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-render if the last text block content changed
|
||||||
const prevLastTextContent = getLastBlockContent(prevBlocks as any[], 'text')
|
const prevLastTextContent = getLastBlockContent(prevBlocks as any[], 'text')
|
||||||
const nextLastTextContent = getLastBlockContent(nextBlocks as any[], 'text')
|
const nextLastTextContent = getLastBlockContent(nextBlocks as any[], 'text')
|
||||||
if (
|
if (
|
||||||
@@ -497,6 +609,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-render if the last thinking block content changed
|
||||||
const prevLastThinkingContent = getLastBlockContent(prevBlocks as any[], 'thinking')
|
const prevLastThinkingContent = getLastBlockContent(prevBlocks as any[], 'thinking')
|
||||||
const nextLastThinkingContent = getLastBlockContent(nextBlocks as any[], 'thinking')
|
const nextLastThinkingContent = getLastBlockContent(nextBlocks as any[], 'thinking')
|
||||||
if (
|
if (
|
||||||
@@ -507,18 +620,24 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if tool calls changed
|
||||||
const prevToolCalls = prevMessage.toolCalls || []
|
const prevToolCalls = prevMessage.toolCalls || []
|
||||||
const nextToolCalls = nextMessage.toolCalls || []
|
const nextToolCalls = nextMessage.toolCalls || []
|
||||||
|
|
||||||
if (prevToolCalls.length !== nextToolCalls.length) return false
|
if (prevToolCalls.length !== nextToolCalls.length) {
|
||||||
|
return false // Tool calls count changed
|
||||||
|
}
|
||||||
|
|
||||||
for (let i = 0; i < nextToolCalls.length; i++) {
|
for (let i = 0; i < nextToolCalls.length; i++) {
|
||||||
if (prevToolCalls[i]?.state !== nextToolCalls[i]?.state) return false
|
if (prevToolCalls[i]?.state !== nextToolCalls[i]?.state) {
|
||||||
|
return false // Tool call state changed
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For non-streaming messages, do a deeper comparison including tool call states
|
||||||
if (
|
if (
|
||||||
prevMessage.content !== nextMessage.content ||
|
prevMessage.content !== nextMessage.content ||
|
||||||
prevMessage.role !== nextMessage.role ||
|
prevMessage.role !== nextMessage.role ||
|
||||||
@@ -528,12 +647,16 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check tool call states for non-streaming messages too
|
||||||
const prevToolCalls = prevMessage.toolCalls || []
|
const prevToolCalls = prevMessage.toolCalls || []
|
||||||
const nextToolCalls = nextMessage.toolCalls || []
|
const nextToolCalls = nextMessage.toolCalls || []
|
||||||
for (let i = 0; i < nextToolCalls.length; i++) {
|
for (let i = 0; i < nextToolCalls.length; i++) {
|
||||||
if (prevToolCalls[i]?.state !== nextToolCalls[i]?.state) return false
|
if (prevToolCalls[i]?.state !== nextToolCalls[i]?.state) {
|
||||||
|
return false // Tool call state changed
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check contentBlocks tool call states
|
||||||
const prevContentBlocks = prevMessage.contentBlocks || []
|
const prevContentBlocks = prevMessage.contentBlocks || []
|
||||||
const nextContentBlocks = nextMessage.contentBlocks || []
|
const nextContentBlocks = nextMessage.contentBlocks || []
|
||||||
for (let i = 0; i < nextContentBlocks.length; i++) {
|
for (let i = 0; i < nextContentBlocks.length; i++) {
|
||||||
@@ -544,7 +667,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
|||||||
nextBlock?.type === 'tool_call' &&
|
nextBlock?.type === 'tool_call' &&
|
||||||
prevBlock.toolCall?.state !== nextBlock.toolCall?.state
|
prevBlock.toolCall?.state !== nextBlock.toolCall?.state
|
||||||
) {
|
) {
|
||||||
return false
|
return false // ContentBlock tool call state changed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ const logger = createLogger('useCheckpointManagement')
|
|||||||
* @param messageCheckpoints - Checkpoints for this message
|
* @param messageCheckpoints - Checkpoints for this message
|
||||||
* @param onRevertModeChange - Callback for revert mode changes
|
* @param onRevertModeChange - Callback for revert mode changes
|
||||||
* @param onEditModeChange - Callback for edit mode changes
|
* @param onEditModeChange - Callback for edit mode changes
|
||||||
* @param onCancelEdit - Callback when edit is cancelled
|
|
||||||
* @returns Checkpoint management utilities
|
* @returns Checkpoint management utilities
|
||||||
*/
|
*/
|
||||||
export function useCheckpointManagement(
|
export function useCheckpointManagement(
|
||||||
@@ -38,13 +37,17 @@ export function useCheckpointManagement(
|
|||||||
|
|
||||||
const { revertToCheckpoint, currentChat } = useCopilotStore()
|
const { revertToCheckpoint, currentChat } = useCopilotStore()
|
||||||
|
|
||||||
/** Initiates checkpoint revert confirmation */
|
/**
|
||||||
|
* Handles initiating checkpoint revert
|
||||||
|
*/
|
||||||
const handleRevertToCheckpoint = useCallback(() => {
|
const handleRevertToCheckpoint = useCallback(() => {
|
||||||
setShowRestoreConfirmation(true)
|
setShowRestoreConfirmation(true)
|
||||||
onRevertModeChange?.(true)
|
onRevertModeChange?.(true)
|
||||||
}, [onRevertModeChange])
|
}, [onRevertModeChange])
|
||||||
|
|
||||||
/** Confirms and executes checkpoint revert */
|
/**
|
||||||
|
* Confirms checkpoint revert and updates state
|
||||||
|
*/
|
||||||
const handleConfirmRevert = useCallback(async () => {
|
const handleConfirmRevert = useCallback(async () => {
|
||||||
if (messageCheckpoints.length > 0) {
|
if (messageCheckpoints.length > 0) {
|
||||||
const latestCheckpoint = messageCheckpoints[0]
|
const latestCheckpoint = messageCheckpoints[0]
|
||||||
@@ -113,13 +116,18 @@ export function useCheckpointManagement(
|
|||||||
onRevertModeChange,
|
onRevertModeChange,
|
||||||
])
|
])
|
||||||
|
|
||||||
/** Cancels checkpoint revert */
|
/**
|
||||||
|
* Cancels checkpoint revert
|
||||||
|
*/
|
||||||
const handleCancelRevert = useCallback(() => {
|
const handleCancelRevert = useCallback(() => {
|
||||||
setShowRestoreConfirmation(false)
|
setShowRestoreConfirmation(false)
|
||||||
onRevertModeChange?.(false)
|
onRevertModeChange?.(false)
|
||||||
}, [onRevertModeChange])
|
}, [onRevertModeChange])
|
||||||
|
|
||||||
/** Reverts to checkpoint then proceeds with pending edit */
|
/**
|
||||||
|
* Handles "Continue and revert" action for checkpoint discard modal
|
||||||
|
* Reverts to checkpoint then proceeds with pending edit
|
||||||
|
*/
|
||||||
const handleContinueAndRevert = useCallback(async () => {
|
const handleContinueAndRevert = useCallback(async () => {
|
||||||
setIsProcessingDiscard(true)
|
setIsProcessingDiscard(true)
|
||||||
try {
|
try {
|
||||||
@@ -176,7 +184,9 @@ export function useCheckpointManagement(
|
|||||||
}
|
}
|
||||||
}, [messageCheckpoints, revertToCheckpoint, message, messages, onEditModeChange, onCancelEdit])
|
}, [messageCheckpoints, revertToCheckpoint, message, messages, onEditModeChange, onCancelEdit])
|
||||||
|
|
||||||
/** Cancels checkpoint discard and clears pending edit */
|
/**
|
||||||
|
* Cancels checkpoint discard and clears pending edit
|
||||||
|
*/
|
||||||
const handleCancelCheckpointDiscard = useCallback(() => {
|
const handleCancelCheckpointDiscard = useCallback(() => {
|
||||||
setShowCheckpointDiscardModal(false)
|
setShowCheckpointDiscardModal(false)
|
||||||
onEditModeChange?.(false)
|
onEditModeChange?.(false)
|
||||||
@@ -184,11 +194,11 @@ export function useCheckpointManagement(
|
|||||||
pendingEditRef.current = null
|
pendingEditRef.current = null
|
||||||
}, [onEditModeChange, onCancelEdit])
|
}, [onEditModeChange, onCancelEdit])
|
||||||
|
|
||||||
/** Continues with edit without reverting checkpoint */
|
/**
|
||||||
|
* Continues with edit WITHOUT reverting checkpoint
|
||||||
|
*/
|
||||||
const handleContinueWithoutRevert = useCallback(async () => {
|
const handleContinueWithoutRevert = useCallback(async () => {
|
||||||
setShowCheckpointDiscardModal(false)
|
setShowCheckpointDiscardModal(false)
|
||||||
onEditModeChange?.(false)
|
|
||||||
onCancelEdit?.()
|
|
||||||
|
|
||||||
if (pendingEditRef.current) {
|
if (pendingEditRef.current) {
|
||||||
const { message: msg, fileAttachments, contexts } = pendingEditRef.current
|
const { message: msg, fileAttachments, contexts } = pendingEditRef.current
|
||||||
@@ -215,34 +225,43 @@ export function useCheckpointManagement(
|
|||||||
}
|
}
|
||||||
}, [message, messages, onEditModeChange, onCancelEdit])
|
}, [message, messages, onEditModeChange, onCancelEdit])
|
||||||
|
|
||||||
/** Handles keyboard events for confirmation dialogs */
|
/**
|
||||||
|
* Handles keyboard events for restore confirmation (Escape/Enter)
|
||||||
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const isActive = showRestoreConfirmation || showCheckpointDiscardModal
|
if (!showRestoreConfirmation) return
|
||||||
if (!isActive) return
|
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (event.defaultPrevented) return
|
|
||||||
|
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
if (showRestoreConfirmation) handleCancelRevert()
|
handleCancelRevert()
|
||||||
else handleCancelCheckpointDiscard()
|
|
||||||
} else if (event.key === 'Enter') {
|
} else if (event.key === 'Enter') {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (showRestoreConfirmation) handleConfirmRevert()
|
handleConfirmRevert()
|
||||||
else handleContinueAndRevert()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('keydown', handleKeyDown)
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||||
}, [
|
}, [showRestoreConfirmation, handleCancelRevert, handleConfirmRevert])
|
||||||
showRestoreConfirmation,
|
|
||||||
showCheckpointDiscardModal,
|
/**
|
||||||
handleCancelRevert,
|
* Handles keyboard events for checkpoint discard modal (Escape/Enter)
|
||||||
handleConfirmRevert,
|
*/
|
||||||
handleCancelCheckpointDiscard,
|
useEffect(() => {
|
||||||
handleContinueAndRevert,
|
if (!showCheckpointDiscardModal) return
|
||||||
])
|
|
||||||
|
const handleCheckpointDiscardKeyDown = async (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
handleCancelCheckpointDiscard()
|
||||||
|
} else if (event.key === 'Enter') {
|
||||||
|
event.preventDefault()
|
||||||
|
await handleContinueAndRevert()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleCheckpointDiscardKeyDown)
|
||||||
|
return () => document.removeEventListener('keydown', handleCheckpointDiscardKeyDown)
|
||||||
|
}, [showCheckpointDiscardModal, handleCancelCheckpointDiscard, handleContinueAndRevert])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// State
|
// State
|
||||||
|
|||||||
@@ -2,23 +2,24 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import type { ChatContext, CopilotMessage, MessageFileAttachment } from '@/stores/panel'
|
import type { CopilotMessage } from '@/stores/panel'
|
||||||
import { useCopilotStore } from '@/stores/panel'
|
import { useCopilotStore } from '@/stores/panel'
|
||||||
|
|
||||||
const logger = createLogger('useMessageEditing')
|
const logger = createLogger('useMessageEditing')
|
||||||
|
|
||||||
/** Ref interface for UserInput component */
|
/**
|
||||||
interface UserInputRef {
|
* Message truncation height in pixels
|
||||||
focus: () => void
|
*/
|
||||||
}
|
|
||||||
|
|
||||||
/** Message truncation height in pixels */
|
|
||||||
const MESSAGE_TRUNCATION_HEIGHT = 60
|
const MESSAGE_TRUNCATION_HEIGHT = 60
|
||||||
|
|
||||||
/** Delay before attaching click-outside listener to avoid immediate trigger */
|
/**
|
||||||
|
* Delay before attaching click-outside listener to avoid immediate trigger
|
||||||
|
*/
|
||||||
const CLICK_OUTSIDE_DELAY = 100
|
const CLICK_OUTSIDE_DELAY = 100
|
||||||
|
|
||||||
/** Delay before aborting when editing during stream */
|
/**
|
||||||
|
* Delay before aborting when editing during stream
|
||||||
|
*/
|
||||||
const ABORT_DELAY = 100
|
const ABORT_DELAY = 100
|
||||||
|
|
||||||
interface UseMessageEditingProps {
|
interface UseMessageEditingProps {
|
||||||
@@ -31,8 +32,8 @@ interface UseMessageEditingProps {
|
|||||||
setShowCheckpointDiscardModal: (show: boolean) => void
|
setShowCheckpointDiscardModal: (show: boolean) => void
|
||||||
pendingEditRef: React.MutableRefObject<{
|
pendingEditRef: React.MutableRefObject<{
|
||||||
message: string
|
message: string
|
||||||
fileAttachments?: MessageFileAttachment[]
|
fileAttachments?: any[]
|
||||||
contexts?: ChatContext[]
|
contexts?: any[]
|
||||||
} | null>
|
} | null>
|
||||||
/**
|
/**
|
||||||
* When true, disables the internal document click-outside handler.
|
* When true, disables the internal document click-outside handler.
|
||||||
@@ -68,11 +69,13 @@ export function useMessageEditing(props: UseMessageEditingProps) {
|
|||||||
|
|
||||||
const editContainerRef = useRef<HTMLDivElement>(null)
|
const editContainerRef = useRef<HTMLDivElement>(null)
|
||||||
const messageContentRef = useRef<HTMLDivElement>(null)
|
const messageContentRef = useRef<HTMLDivElement>(null)
|
||||||
const userInputRef = useRef<UserInputRef>(null)
|
const userInputRef = useRef<any>(null)
|
||||||
|
|
||||||
const { sendMessage, isSendingMessage, abortMessage, currentChat } = useCopilotStore()
|
const { sendMessage, isSendingMessage, abortMessage, currentChat } = useCopilotStore()
|
||||||
|
|
||||||
/** Checks if message content needs expansion based on height */
|
/**
|
||||||
|
* Checks if message content needs expansion based on height
|
||||||
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (messageContentRef.current && message.role === 'user') {
|
if (messageContentRef.current && message.role === 'user') {
|
||||||
const scrollHeight = messageContentRef.current.scrollHeight
|
const scrollHeight = messageContentRef.current.scrollHeight
|
||||||
@@ -80,7 +83,9 @@ export function useMessageEditing(props: UseMessageEditingProps) {
|
|||||||
}
|
}
|
||||||
}, [message.content, message.role])
|
}, [message.content, message.role])
|
||||||
|
|
||||||
/** Enters edit mode */
|
/**
|
||||||
|
* Handles entering edit mode
|
||||||
|
*/
|
||||||
const handleEditMessage = useCallback(() => {
|
const handleEditMessage = useCallback(() => {
|
||||||
setIsEditMode(true)
|
setIsEditMode(true)
|
||||||
setIsExpanded(false)
|
setIsExpanded(false)
|
||||||
@@ -92,14 +97,18 @@ export function useMessageEditing(props: UseMessageEditingProps) {
|
|||||||
}, 0)
|
}, 0)
|
||||||
}, [message.content, onEditModeChange])
|
}, [message.content, onEditModeChange])
|
||||||
|
|
||||||
/** Cancels edit mode */
|
/**
|
||||||
|
* Handles canceling edit mode
|
||||||
|
*/
|
||||||
const handleCancelEdit = useCallback(() => {
|
const handleCancelEdit = useCallback(() => {
|
||||||
setIsEditMode(false)
|
setIsEditMode(false)
|
||||||
setEditedContent(message.content)
|
setEditedContent(message.content)
|
||||||
onEditModeChange?.(false)
|
onEditModeChange?.(false)
|
||||||
}, [message.content, onEditModeChange])
|
}, [message.content, onEditModeChange])
|
||||||
|
|
||||||
/** Handles message click to enter edit mode */
|
/**
|
||||||
|
* Handles clicking on message to enter edit mode
|
||||||
|
*/
|
||||||
const handleMessageClick = useCallback(() => {
|
const handleMessageClick = useCallback(() => {
|
||||||
if (needsExpansion && !isExpanded) {
|
if (needsExpansion && !isExpanded) {
|
||||||
setIsExpanded(true)
|
setIsExpanded(true)
|
||||||
@@ -107,13 +116,12 @@ export function useMessageEditing(props: UseMessageEditingProps) {
|
|||||||
handleEditMessage()
|
handleEditMessage()
|
||||||
}, [needsExpansion, isExpanded, handleEditMessage])
|
}, [needsExpansion, isExpanded, handleEditMessage])
|
||||||
|
|
||||||
/** Performs the edit operation - truncates messages after edited message and resends */
|
/**
|
||||||
|
* Performs the actual edit operation
|
||||||
|
* Truncates messages after edited message and resends with same ID
|
||||||
|
*/
|
||||||
const performEdit = useCallback(
|
const performEdit = useCallback(
|
||||||
async (
|
async (editedMessage: string, fileAttachments?: any[], contexts?: any[]) => {
|
||||||
editedMessage: string,
|
|
||||||
fileAttachments?: MessageFileAttachment[],
|
|
||||||
contexts?: ChatContext[]
|
|
||||||
) => {
|
|
||||||
const currentMessages = messages
|
const currentMessages = messages
|
||||||
const editIndex = currentMessages.findIndex((m) => m.id === message.id)
|
const editIndex = currentMessages.findIndex((m) => m.id === message.id)
|
||||||
|
|
||||||
@@ -126,7 +134,7 @@ export function useMessageEditing(props: UseMessageEditingProps) {
|
|||||||
...message,
|
...message,
|
||||||
content: editedMessage,
|
content: editedMessage,
|
||||||
fileAttachments: fileAttachments || message.fileAttachments,
|
fileAttachments: fileAttachments || message.fileAttachments,
|
||||||
contexts: contexts || message.contexts,
|
contexts: contexts || (message as any).contexts,
|
||||||
}
|
}
|
||||||
|
|
||||||
useCopilotStore.setState({ messages: [...truncatedMessages, updatedMessage] })
|
useCopilotStore.setState({ messages: [...truncatedMessages, updatedMessage] })
|
||||||
@@ -145,7 +153,7 @@ export function useMessageEditing(props: UseMessageEditingProps) {
|
|||||||
timestamp: m.timestamp,
|
timestamp: m.timestamp,
|
||||||
...(m.contentBlocks && { contentBlocks: m.contentBlocks }),
|
...(m.contentBlocks && { contentBlocks: m.contentBlocks }),
|
||||||
...(m.fileAttachments && { fileAttachments: m.fileAttachments }),
|
...(m.fileAttachments && { fileAttachments: m.fileAttachments }),
|
||||||
...(m.contexts && { contexts: m.contexts }),
|
...((m as any).contexts && { contexts: (m as any).contexts }),
|
||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -156,7 +164,7 @@ export function useMessageEditing(props: UseMessageEditingProps) {
|
|||||||
|
|
||||||
await sendMessage(editedMessage, {
|
await sendMessage(editedMessage, {
|
||||||
fileAttachments: fileAttachments || message.fileAttachments,
|
fileAttachments: fileAttachments || message.fileAttachments,
|
||||||
contexts: contexts || message.contexts,
|
contexts: contexts || (message as any).contexts,
|
||||||
messageId: message.id,
|
messageId: message.id,
|
||||||
queueIfBusy: false,
|
queueIfBusy: false,
|
||||||
})
|
})
|
||||||
@@ -165,13 +173,12 @@ export function useMessageEditing(props: UseMessageEditingProps) {
|
|||||||
[messages, message, currentChat, sendMessage, onEditModeChange]
|
[messages, message, currentChat, sendMessage, onEditModeChange]
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Submits edited message, checking for checkpoints first */
|
/**
|
||||||
|
* Handles submitting edited message
|
||||||
|
* Checks for checkpoints and shows confirmation if needed
|
||||||
|
*/
|
||||||
const handleSubmitEdit = useCallback(
|
const handleSubmitEdit = useCallback(
|
||||||
async (
|
async (editedMessage: string, fileAttachments?: any[], contexts?: any[]) => {
|
||||||
editedMessage: string,
|
|
||||||
fileAttachments?: MessageFileAttachment[],
|
|
||||||
contexts?: ChatContext[]
|
|
||||||
) => {
|
|
||||||
if (!editedMessage.trim()) return
|
if (!editedMessage.trim()) return
|
||||||
|
|
||||||
if (isSendingMessage) {
|
if (isSendingMessage) {
|
||||||
@@ -197,7 +204,9 @@ export function useMessageEditing(props: UseMessageEditingProps) {
|
|||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Keyboard-only exit (Esc) */
|
/**
|
||||||
|
* Keyboard-only exit (Esc). Click-outside is optionally handled by parent.
|
||||||
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isEditMode) return
|
if (!isEditMode) return
|
||||||
|
|
||||||
@@ -213,7 +222,9 @@ export function useMessageEditing(props: UseMessageEditingProps) {
|
|||||||
}
|
}
|
||||||
}, [isEditMode, handleCancelEdit])
|
}, [isEditMode, handleCancelEdit])
|
||||||
|
|
||||||
/** Optional document-level click-outside handler */
|
/**
|
||||||
|
* Optional document-level click-outside handler (disabled when parent manages it).
|
||||||
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isEditMode || disableDocumentClickOutside) return
|
if (!isEditMode || disableDocumentClickOutside) return
|
||||||
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export * from './copilot-message'
|
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
export * from './chat-history-skeleton'
|
export * from './copilot-message/copilot-message'
|
||||||
export * from './copilot-message'
|
export * from './plan-mode-section/plan-mode-section'
|
||||||
export * from './plan-mode-section'
|
export * from './queued-messages/queued-messages'
|
||||||
export * from './queued-messages'
|
export * from './todo-list/todo-list'
|
||||||
export * from './todo-list'
|
export * from './tool-call/tool-call'
|
||||||
export * from './tool-call'
|
export * from './user-input/user-input'
|
||||||
export * from './user-input'
|
export * from './welcome/welcome'
|
||||||
export * from './welcome'
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export * from './plan-mode-section'
|
|
||||||
@@ -29,7 +29,7 @@ import { Check, GripHorizontal, Pencil, X } from 'lucide-react'
|
|||||||
import { Button, Textarea } from '@/components/emcn'
|
import { Button, Textarea } from '@/components/emcn'
|
||||||
import { Trash } from '@/components/emcn/icons/trash'
|
import { Trash } from '@/components/emcn/icons/trash'
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
import { CopilotMarkdownRenderer } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
|
import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared border and background styles
|
* Shared border and background styles
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export * from './queued-messages'
|
|
||||||
@@ -31,22 +31,21 @@ export function QueuedMessages() {
|
|||||||
if (messageQueue.length === 0) return null
|
if (messageQueue.length === 0) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='mx-[14px] overflow-hidden rounded-t-[4px] border border-[var(--border)] border-b-0 bg-[var(--bg-secondary)]'>
|
<div className='mx-2 overflow-hidden rounded-t-lg border border-black/[0.08] border-b-0 bg-[var(--bg-secondary)] dark:border-white/[0.08]'>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<button
|
<button
|
||||||
type='button'
|
type='button'
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
className='flex w-full items-center justify-between px-[10px] py-[6px] transition-colors hover:bg-[var(--surface-3)]'
|
className='flex w-full items-center justify-between px-2.5 py-1.5 transition-colors hover:bg-[var(--bg-tertiary)]'
|
||||||
>
|
>
|
||||||
<div className='flex items-center gap-[6px]'>
|
<div className='flex items-center gap-1.5'>
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
<ChevronDown className='h-[14px] w-[14px] text-[var(--text-tertiary)]' />
|
<ChevronDown className='h-3 w-3 text-[var(--text-tertiary)]' />
|
||||||
) : (
|
) : (
|
||||||
<ChevronRight className='h-[14px] w-[14px] text-[var(--text-tertiary)]' />
|
<ChevronRight className='h-3 w-3 text-[var(--text-tertiary)]' />
|
||||||
)}
|
)}
|
||||||
<span className='font-medium text-[12px] text-[var(--text-primary)]'>Queued</span>
|
<span className='font-medium text-[var(--text-secondary)] text-xs'>
|
||||||
<span className='flex-shrink-0 font-medium text-[12px] text-[var(--text-tertiary)]'>
|
{messageQueue.length} Queued
|
||||||
{messageQueue.length}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -57,30 +56,30 @@ export function QueuedMessages() {
|
|||||||
{messageQueue.map((msg) => (
|
{messageQueue.map((msg) => (
|
||||||
<div
|
<div
|
||||||
key={msg.id}
|
key={msg.id}
|
||||||
className='group flex items-center gap-[8px] border-[var(--border)] border-t px-[10px] py-[6px] hover:bg-[var(--surface-3)]'
|
className='group flex items-center gap-2 border-black/[0.04] border-t px-2.5 py-1.5 hover:bg-[var(--bg-tertiary)] dark:border-white/[0.04]'
|
||||||
>
|
>
|
||||||
{/* Radio indicator */}
|
{/* Radio indicator */}
|
||||||
<div className='flex h-[14px] w-[14px] shrink-0 items-center justify-center'>
|
<div className='flex h-3 w-3 shrink-0 items-center justify-center'>
|
||||||
<div className='h-[10px] w-[10px] rounded-full border border-[var(--text-tertiary)]/50' />
|
<div className='h-2.5 w-2.5 rounded-full border border-[var(--text-tertiary)]/50' />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Message content */}
|
{/* Message content */}
|
||||||
<div className='min-w-0 flex-1'>
|
<div className='min-w-0 flex-1'>
|
||||||
<p className='truncate text-[13px] text-[var(--text-primary)]'>{msg.content}</p>
|
<p className='truncate text-[var(--text-primary)] text-xs'>{msg.content}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions - always visible */}
|
{/* Actions - always visible */}
|
||||||
<div className='flex shrink-0 items-center gap-[4px]'>
|
<div className='flex shrink-0 items-center gap-0.5'>
|
||||||
<button
|
<button
|
||||||
type='button'
|
type='button'
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
handleSendNow(msg.id)
|
handleSendNow(msg.id)
|
||||||
}}
|
}}
|
||||||
className='rounded p-[3px] text-[var(--text-tertiary)] transition-colors hover:bg-[var(--bg-quaternary)] hover:text-[var(--text-primary)]'
|
className='rounded p-0.5 text-[var(--text-tertiary)] transition-colors hover:bg-[var(--bg-quaternary)] hover:text-[var(--text-primary)]'
|
||||||
title='Send now (aborts current stream)'
|
title='Send now (aborts current stream)'
|
||||||
>
|
>
|
||||||
<ArrowUp className='h-[14px] w-[14px]' />
|
<ArrowUp className='h-3 w-3' />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type='button'
|
type='button'
|
||||||
@@ -88,10 +87,10 @@ export function QueuedMessages() {
|
|||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
handleRemove(msg.id)
|
handleRemove(msg.id)
|
||||||
}}
|
}}
|
||||||
className='rounded p-[3px] text-[var(--text-tertiary)] transition-colors hover:bg-red-500/10 hover:text-red-400'
|
className='rounded p-0.5 text-[var(--text-tertiary)] transition-colors hover:bg-red-500/10 hover:text-red-400'
|
||||||
title='Remove from queue'
|
title='Remove from queue'
|
||||||
>
|
>
|
||||||
<Trash2 className='h-[14px] w-[14px]' />
|
<Trash2 className='h-3 w-3' />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export * from './todo-list'
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from './tool-call'
|
|
||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
hasInterrupt as hasInterruptFromConfig,
|
hasInterrupt as hasInterruptFromConfig,
|
||||||
isSpecialTool as isSpecialToolFromConfig,
|
isSpecialTool as isSpecialToolFromConfig,
|
||||||
} from '@/lib/copilot/tools/client/ui-config'
|
} from '@/lib/copilot/tools/client/ui-config'
|
||||||
import { CopilotMarkdownRenderer } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
|
import CopilotMarkdownRenderer from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/markdown-renderer'
|
||||||
import { SmoothStreamingText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming'
|
import { SmoothStreamingText } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming'
|
||||||
import { ThinkingBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block'
|
import { ThinkingBlock } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/thinking-block'
|
||||||
import { getDisplayValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
|
import { getDisplayValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/workflow-block'
|
||||||
@@ -26,30 +26,27 @@ import { CLASS_TOOL_METADATA } from '@/stores/panel/copilot/store'
|
|||||||
import type { SubAgentContentBlock } from '@/stores/panel/copilot/types'
|
import type { SubAgentContentBlock } from '@/stores/panel/copilot/types'
|
||||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
||||||
|
|
||||||
/** Plan step can be a string or an object with title and optional plan content */
|
/**
|
||||||
|
* Plan step can be either a string or an object with title and plan
|
||||||
|
*/
|
||||||
type PlanStep = string | { title: string; plan?: string }
|
type PlanStep = string | { title: string; plan?: string }
|
||||||
|
|
||||||
/** Option can be a string or an object with title and optional description */
|
/**
|
||||||
|
* Option can be either a string or an object with title and description
|
||||||
|
*/
|
||||||
type OptionItem = string | { title: string; description?: string }
|
type OptionItem = string | { title: string; description?: string }
|
||||||
|
|
||||||
/** Result of parsing special XML tags from message content */
|
|
||||||
interface ParsedTags {
|
interface ParsedTags {
|
||||||
/** Parsed plan steps, keyed by step number */
|
|
||||||
plan?: Record<string, PlanStep>
|
plan?: Record<string, PlanStep>
|
||||||
/** Whether the plan tag is complete (has closing tag) */
|
|
||||||
planComplete?: boolean
|
planComplete?: boolean
|
||||||
/** Parsed options, keyed by option number */
|
|
||||||
options?: Record<string, OptionItem>
|
options?: Record<string, OptionItem>
|
||||||
/** Whether the options tag is complete (has closing tag) */
|
|
||||||
optionsComplete?: boolean
|
optionsComplete?: boolean
|
||||||
/** Content with special tags removed */
|
|
||||||
cleanContent: string
|
cleanContent: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts plan steps from plan_respond tool calls in subagent blocks.
|
* Extract plan steps from plan_respond tool calls in subagent blocks.
|
||||||
* @param blocks - The subagent content blocks to search
|
* Returns { steps, isComplete } where steps is in the format expected by PlanSteps component.
|
||||||
* @returns Object containing steps in the format expected by PlanSteps component, and completion status
|
|
||||||
*/
|
*/
|
||||||
function extractPlanFromBlocks(blocks: SubAgentContentBlock[] | undefined): {
|
function extractPlanFromBlocks(blocks: SubAgentContentBlock[] | undefined): {
|
||||||
steps: Record<string, PlanStep> | undefined
|
steps: Record<string, PlanStep> | undefined
|
||||||
@@ -57,6 +54,7 @@ function extractPlanFromBlocks(blocks: SubAgentContentBlock[] | undefined): {
|
|||||||
} {
|
} {
|
||||||
if (!blocks) return { steps: undefined, isComplete: false }
|
if (!blocks) return { steps: undefined, isComplete: false }
|
||||||
|
|
||||||
|
// Find the plan_respond tool call
|
||||||
const planRespondBlock = blocks.find(
|
const planRespondBlock = blocks.find(
|
||||||
(b) => b.type === 'subagent_tool_call' && b.toolCall?.name === 'plan_respond'
|
(b) => b.type === 'subagent_tool_call' && b.toolCall?.name === 'plan_respond'
|
||||||
)
|
)
|
||||||
@@ -65,6 +63,8 @@ function extractPlanFromBlocks(blocks: SubAgentContentBlock[] | undefined): {
|
|||||||
return { steps: undefined, isComplete: false }
|
return { steps: undefined, isComplete: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tool call arguments can be in different places depending on the source
|
||||||
|
// Also handle nested data.arguments structure from the schema
|
||||||
const tc = planRespondBlock.toolCall as any
|
const tc = planRespondBlock.toolCall as any
|
||||||
const args = tc.params || tc.parameters || tc.input || tc.arguments || tc.data?.arguments || {}
|
const args = tc.params || tc.parameters || tc.input || tc.arguments || tc.data?.arguments || {}
|
||||||
const stepsArray = args.steps
|
const stepsArray = args.steps
|
||||||
@@ -73,6 +73,9 @@ function extractPlanFromBlocks(blocks: SubAgentContentBlock[] | undefined): {
|
|||||||
return { steps: undefined, isComplete: false }
|
return { steps: undefined, isComplete: false }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert array format to Record<string, PlanStep> format
|
||||||
|
// From: [{ number: 1, title: "..." }, { number: 2, title: "..." }]
|
||||||
|
// To: { "1": "...", "2": "..." }
|
||||||
const steps: Record<string, PlanStep> = {}
|
const steps: Record<string, PlanStep> = {}
|
||||||
for (const step of stepsArray) {
|
for (const step of stepsArray) {
|
||||||
if (step.number !== undefined && step.title) {
|
if (step.number !== undefined && step.title) {
|
||||||
@@ -80,6 +83,7 @@ function extractPlanFromBlocks(blocks: SubAgentContentBlock[] | undefined): {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if the tool call is complete (not pending/executing)
|
||||||
const isComplete =
|
const isComplete =
|
||||||
planRespondBlock.toolCall.state === ClientToolCallState.success ||
|
planRespondBlock.toolCall.state === ClientToolCallState.success ||
|
||||||
planRespondBlock.toolCall.state === ClientToolCallState.error
|
planRespondBlock.toolCall.state === ClientToolCallState.error
|
||||||
@@ -91,9 +95,8 @@ function extractPlanFromBlocks(blocks: SubAgentContentBlock[] | undefined): {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses partial JSON for streaming options, extracting complete key-value pairs from incomplete JSON.
|
* Try to parse partial JSON for streaming options.
|
||||||
* @param jsonStr - The potentially incomplete JSON string
|
* Attempts to extract complete key-value pairs from incomplete JSON.
|
||||||
* @returns Parsed options record or null if no valid options found
|
|
||||||
*/
|
*/
|
||||||
function parsePartialOptionsJson(jsonStr: string): Record<string, OptionItem> | null {
|
function parsePartialOptionsJson(jsonStr: string): Record<string, OptionItem> | null {
|
||||||
// Try parsing as-is first (might be complete)
|
// Try parsing as-is first (might be complete)
|
||||||
@@ -104,9 +107,8 @@ function parsePartialOptionsJson(jsonStr: string): Record<string, OptionItem> |
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Try to extract complete key-value pairs from partial JSON
|
// Try to extract complete key-value pairs from partial JSON
|
||||||
// Match patterns like "1": "some text" or "1": {"title": "text", "description": "..."}
|
// Match patterns like "1": "some text" or "1": {"title": "text"}
|
||||||
const result: Record<string, OptionItem> = {}
|
const result: Record<string, OptionItem> = {}
|
||||||
|
|
||||||
// Match complete string values: "key": "value"
|
// Match complete string values: "key": "value"
|
||||||
const stringPattern = /"(\d+)":\s*"([^"]*?)"/g
|
const stringPattern = /"(\d+)":\s*"([^"]*?)"/g
|
||||||
let match
|
let match
|
||||||
@@ -114,24 +116,18 @@ function parsePartialOptionsJson(jsonStr: string): Record<string, OptionItem> |
|
|||||||
result[match[1]] = match[2]
|
result[match[1]] = match[2]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match complete object values with title and optional description
|
// Match complete object values: "key": {"title": "value"}
|
||||||
// Pattern matches: "1": {"title": "...", "description": "..."} or "1": {"title": "..."}
|
const objectPattern = /"(\d+)":\s*\{[^}]*"title":\s*"([^"]*)"[^}]*\}/g
|
||||||
const objectPattern =
|
|
||||||
/"(\d+)":\s*\{\s*"title":\s*"((?:[^"\\]|\\.)*)"\s*(?:,\s*"description":\s*"((?:[^"\\]|\\.)*)")?\s*\}/g
|
|
||||||
while ((match = objectPattern.exec(jsonStr)) !== null) {
|
while ((match = objectPattern.exec(jsonStr)) !== null) {
|
||||||
const key = match[1]
|
result[match[1]] = { title: match[2] }
|
||||||
const title = match[2].replace(/\\"/g, '"').replace(/\\n/g, '\n')
|
|
||||||
const description = match[3]?.replace(/\\"/g, '"').replace(/\\n/g, '\n')
|
|
||||||
result[key] = description ? { title, description } : { title }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Object.keys(result).length > 0 ? result : null
|
return Object.keys(result).length > 0 ? result : null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses partial JSON for streaming plan steps, extracting complete key-value pairs from incomplete JSON.
|
* Try to parse partial JSON for streaming plan steps.
|
||||||
* @param jsonStr - The potentially incomplete JSON string
|
* Attempts to extract complete key-value pairs from incomplete JSON.
|
||||||
* @returns Parsed plan steps record or null if no valid steps found
|
|
||||||
*/
|
*/
|
||||||
function parsePartialPlanJson(jsonStr: string): Record<string, PlanStep> | null {
|
function parsePartialPlanJson(jsonStr: string): Record<string, PlanStep> | null {
|
||||||
// Try parsing as-is first (might be complete)
|
// Try parsing as-is first (might be complete)
|
||||||
@@ -163,10 +159,7 @@ function parsePartialPlanJson(jsonStr: string): Record<string, PlanStep> | null
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parses special XML tags (`<plan>` and `<options>`) from message content.
|
* Parse <plan> and <options> tags from content
|
||||||
* Handles both complete and streaming/incomplete tags.
|
|
||||||
* @param content - The message content to parse
|
|
||||||
* @returns Parsed tags with plan, options, and clean content
|
|
||||||
*/
|
*/
|
||||||
export function parseSpecialTags(content: string): ParsedTags {
|
export function parseSpecialTags(content: string): ParsedTags {
|
||||||
const result: ParsedTags = { cleanContent: content }
|
const result: ParsedTags = { cleanContent: content }
|
||||||
@@ -174,18 +167,12 @@ export function parseSpecialTags(content: string): ParsedTags {
|
|||||||
// Parse <plan> tag - check for complete tag first
|
// Parse <plan> tag - check for complete tag first
|
||||||
const planMatch = content.match(/<plan>([\s\S]*?)<\/plan>/i)
|
const planMatch = content.match(/<plan>([\s\S]*?)<\/plan>/i)
|
||||||
if (planMatch) {
|
if (planMatch) {
|
||||||
// Always strip the tag from display, even if JSON is invalid
|
|
||||||
result.cleanContent = result.cleanContent.replace(planMatch[0], '').trim()
|
|
||||||
try {
|
try {
|
||||||
result.plan = JSON.parse(planMatch[1])
|
result.plan = JSON.parse(planMatch[1])
|
||||||
result.planComplete = true
|
result.planComplete = true
|
||||||
|
result.cleanContent = result.cleanContent.replace(planMatch[0], '').trim()
|
||||||
} catch {
|
} catch {
|
||||||
// JSON.parse failed - use regex fallback to extract plan from malformed JSON
|
// Invalid JSON, ignore
|
||||||
const fallbackPlan = parsePartialPlanJson(planMatch[1])
|
|
||||||
if (fallbackPlan) {
|
|
||||||
result.plan = fallbackPlan
|
|
||||||
result.planComplete = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Check for streaming/incomplete plan tag
|
// Check for streaming/incomplete plan tag
|
||||||
@@ -204,18 +191,12 @@ export function parseSpecialTags(content: string): ParsedTags {
|
|||||||
// Parse <options> tag - check for complete tag first
|
// Parse <options> tag - check for complete tag first
|
||||||
const optionsMatch = content.match(/<options>([\s\S]*?)<\/options>/i)
|
const optionsMatch = content.match(/<options>([\s\S]*?)<\/options>/i)
|
||||||
if (optionsMatch) {
|
if (optionsMatch) {
|
||||||
// Always strip the tag from display, even if JSON is invalid
|
|
||||||
result.cleanContent = result.cleanContent.replace(optionsMatch[0], '').trim()
|
|
||||||
try {
|
try {
|
||||||
result.options = JSON.parse(optionsMatch[1])
|
result.options = JSON.parse(optionsMatch[1])
|
||||||
result.optionsComplete = true
|
result.optionsComplete = true
|
||||||
|
result.cleanContent = result.cleanContent.replace(optionsMatch[0], '').trim()
|
||||||
} catch {
|
} catch {
|
||||||
// JSON.parse failed - use regex fallback to extract options from malformed JSON
|
// Invalid JSON, ignore
|
||||||
const fallbackOptions = parsePartialOptionsJson(optionsMatch[1])
|
|
||||||
if (fallbackOptions) {
|
|
||||||
result.options = fallbackOptions
|
|
||||||
result.optionsComplete = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Check for streaming/incomplete options tag
|
// Check for streaming/incomplete options tag
|
||||||
@@ -239,15 +220,15 @@ export function parseSpecialTags(content: string): ParsedTags {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders workflow plan steps as a numbered to-do list.
|
* PlanSteps component renders the workflow plan steps from the plan subagent
|
||||||
* @param steps - Plan steps keyed by step number
|
* Displays as a to-do list with checkmarks and strikethrough text
|
||||||
* @param streaming - When true, uses smooth streaming animation for step titles
|
|
||||||
*/
|
*/
|
||||||
function PlanSteps({
|
function PlanSteps({
|
||||||
steps,
|
steps,
|
||||||
streaming = false,
|
streaming = false,
|
||||||
}: {
|
}: {
|
||||||
steps: Record<string, PlanStep>
|
steps: Record<string, PlanStep>
|
||||||
|
/** When true, uses smooth streaming animation for step titles */
|
||||||
streaming?: boolean
|
streaming?: boolean
|
||||||
}) {
|
}) {
|
||||||
const sortedSteps = useMemo(() => {
|
const sortedSteps = useMemo(() => {
|
||||||
@@ -268,7 +249,7 @@ function PlanSteps({
|
|||||||
if (sortedSteps.length === 0) return null
|
if (sortedSteps.length === 0) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='mt-0 overflow-hidden rounded-[6px] border border-[var(--border-1)] bg-[var(--surface-1)]'>
|
<div className='mt-1.5 overflow-hidden rounded-[6px] border border-[var(--border-1)] bg-[var(--surface-1)]'>
|
||||||
<div className='flex items-center gap-[8px] border-[var(--border-1)] border-b bg-[var(--surface-2)] p-[8px]'>
|
<div className='flex items-center gap-[8px] border-[var(--border-1)] border-b bg-[var(--surface-2)] p-[8px]'>
|
||||||
<LayoutList className='ml-[2px] h-3 w-3 flex-shrink-0 text-[var(--text-tertiary)]' />
|
<LayoutList className='ml-[2px] h-3 w-3 flex-shrink-0 text-[var(--text-tertiary)]' />
|
||||||
<span className='font-medium text-[12px] text-[var(--text-primary)]'>To-dos</span>
|
<span className='font-medium text-[12px] text-[var(--text-primary)]'>To-dos</span>
|
||||||
@@ -276,7 +257,7 @@ function PlanSteps({
|
|||||||
{sortedSteps.length}
|
{sortedSteps.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex flex-col gap-[6px] px-[10px] py-[6px]'>
|
<div className='flex flex-col gap-[6px] px-[10px] py-[8px]'>
|
||||||
{sortedSteps.map(([num, title], index) => {
|
{sortedSteps.map(([num, title], index) => {
|
||||||
const isLastStep = index === sortedSteps.length - 1
|
const isLastStep = index === sortedSteps.length - 1
|
||||||
return (
|
return (
|
||||||
@@ -300,8 +281,9 @@ function PlanSteps({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders selectable options from the agent with keyboard navigation and click selection.
|
* OptionsSelector component renders selectable options from the agent
|
||||||
* After selection, shows the chosen option highlighted and others struck through.
|
* Supports keyboard navigation (arrow up/down, enter) and click selection
|
||||||
|
* After selection, shows the chosen option highlighted and others struck through
|
||||||
*/
|
*/
|
||||||
export function OptionsSelector({
|
export function OptionsSelector({
|
||||||
options,
|
options,
|
||||||
@@ -309,7 +291,6 @@ export function OptionsSelector({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
enableKeyboardNav = false,
|
enableKeyboardNav = false,
|
||||||
streaming = false,
|
streaming = false,
|
||||||
selectedOptionKey = null,
|
|
||||||
}: {
|
}: {
|
||||||
options: Record<string, OptionItem>
|
options: Record<string, OptionItem>
|
||||||
onSelect: (optionKey: string, optionText: string) => void
|
onSelect: (optionKey: string, optionText: string) => void
|
||||||
@@ -318,8 +299,6 @@ export function OptionsSelector({
|
|||||||
enableKeyboardNav?: boolean
|
enableKeyboardNav?: boolean
|
||||||
/** When true, looks enabled but interaction is disabled (for streaming state) */
|
/** When true, looks enabled but interaction is disabled (for streaming state) */
|
||||||
streaming?: boolean
|
streaming?: boolean
|
||||||
/** Pre-selected option key (for restoring selection from history) */
|
|
||||||
selectedOptionKey?: string | null
|
|
||||||
}) {
|
}) {
|
||||||
const isInteractionDisabled = disabled || streaming
|
const isInteractionDisabled = disabled || streaming
|
||||||
const sortedOptions = useMemo(() => {
|
const sortedOptions = useMemo(() => {
|
||||||
@@ -337,8 +316,8 @@ export function OptionsSelector({
|
|||||||
})
|
})
|
||||||
}, [options])
|
}, [options])
|
||||||
|
|
||||||
const [hoveredIndex, setHoveredIndex] = useState(-1)
|
const [hoveredIndex, setHoveredIndex] = useState(0)
|
||||||
const [chosenKey, setChosenKey] = useState<string | null>(selectedOptionKey)
|
const [chosenKey, setChosenKey] = useState<string | null>(null)
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const isLocked = chosenKey !== null
|
const isLocked = chosenKey !== null
|
||||||
@@ -348,8 +327,7 @@ export function OptionsSelector({
|
|||||||
if (isInteractionDisabled || !enableKeyboardNav || isLocked) return
|
if (isInteractionDisabled || !enableKeyboardNav || isLocked) return
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.defaultPrevented) return
|
// Only handle if the container or document body is focused (not when typing in input)
|
||||||
|
|
||||||
const activeElement = document.activeElement
|
const activeElement = document.activeElement
|
||||||
const isInputFocused =
|
const isInputFocused =
|
||||||
activeElement?.tagName === 'INPUT' ||
|
activeElement?.tagName === 'INPUT' ||
|
||||||
@@ -360,14 +338,13 @@ export function OptionsSelector({
|
|||||||
|
|
||||||
if (e.key === 'ArrowDown') {
|
if (e.key === 'ArrowDown') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setHoveredIndex((prev) => (prev < 0 ? 0 : Math.min(prev + 1, sortedOptions.length - 1)))
|
setHoveredIndex((prev) => Math.min(prev + 1, sortedOptions.length - 1))
|
||||||
} else if (e.key === 'ArrowUp') {
|
} else if (e.key === 'ArrowUp') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setHoveredIndex((prev) => (prev < 0 ? sortedOptions.length - 1 : Math.max(prev - 1, 0)))
|
setHoveredIndex((prev) => Math.max(prev - 1, 0))
|
||||||
} else if (e.key === 'Enter') {
|
} else if (e.key === 'Enter') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const indexToSelect = hoveredIndex < 0 ? 0 : hoveredIndex
|
const selected = sortedOptions[hoveredIndex]
|
||||||
const selected = sortedOptions[indexToSelect]
|
|
||||||
if (selected) {
|
if (selected) {
|
||||||
setChosenKey(selected.key)
|
setChosenKey(selected.key)
|
||||||
onSelect(selected.key, selected.title)
|
onSelect(selected.key, selected.title)
|
||||||
@@ -391,7 +368,7 @@ export function OptionsSelector({
|
|||||||
if (sortedOptions.length === 0) return null
|
if (sortedOptions.length === 0) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className='flex flex-col gap-[4px] pt-[4px]'>
|
<div ref={containerRef} className='flex flex-col gap-0.5 pb-0.5'>
|
||||||
{sortedOptions.map((option, index) => {
|
{sortedOptions.map((option, index) => {
|
||||||
const isHovered = index === hoveredIndex && !isLocked
|
const isHovered = index === hoveredIndex && !isLocked
|
||||||
const isChosen = option.key === chosenKey
|
const isChosen = option.key === chosenKey
|
||||||
@@ -409,9 +386,6 @@ export function OptionsSelector({
|
|||||||
onMouseEnter={() => {
|
onMouseEnter={() => {
|
||||||
if (!isLocked && !streaming) setHoveredIndex(index)
|
if (!isLocked && !streaming) setHoveredIndex(index)
|
||||||
}}
|
}}
|
||||||
onMouseLeave={() => {
|
|
||||||
if (!isLocked && !streaming && sortedOptions.length === 1) setHoveredIndex(-1)
|
|
||||||
}}
|
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'group flex cursor-pointer items-start gap-2 rounded-[6px] p-1',
|
'group flex cursor-pointer items-start gap-2 rounded-[6px] p-1',
|
||||||
'hover:bg-[var(--surface-4)]',
|
'hover:bg-[var(--surface-4)]',
|
||||||
@@ -447,31 +421,30 @@ export function OptionsSelector({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Props for the ToolCall component */
|
|
||||||
interface ToolCallProps {
|
interface ToolCallProps {
|
||||||
/** Tool call data object */
|
|
||||||
toolCall?: CopilotToolCall
|
toolCall?: CopilotToolCall
|
||||||
/** Tool call ID for store lookup */
|
|
||||||
toolCallId?: string
|
toolCallId?: string
|
||||||
/** Callback when tool call state changes */
|
|
||||||
onStateChange?: (state: any) => void
|
onStateChange?: (state: any) => void
|
||||||
/** Whether this tool call is from the current/latest message. Controls shimmer and action buttons. */
|
|
||||||
isCurrentMessage?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Props for the ShimmerOverlayText component */
|
/**
|
||||||
|
* Props for shimmer overlay text component.
|
||||||
|
*/
|
||||||
interface ShimmerOverlayTextProps {
|
interface ShimmerOverlayTextProps {
|
||||||
/** Text content to display */
|
/** The text content to display */
|
||||||
text: string
|
text: string
|
||||||
/** Whether shimmer animation is active */
|
/** Whether the shimmer animation is active */
|
||||||
active?: boolean
|
active?: boolean
|
||||||
/** Additional class names for the wrapper */
|
/** Additional class names for the wrapper */
|
||||||
className?: string
|
className?: string
|
||||||
/** Whether to use special gradient styling for important actions */
|
/** Whether to use special gradient styling (for important actions) */
|
||||||
isSpecial?: boolean
|
isSpecial?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Action verbs at the start of tool display names, highlighted for visual hierarchy */
|
/**
|
||||||
|
* Action verbs that appear at the start of tool display names.
|
||||||
|
* These will be highlighted in a lighter color for better visual hierarchy.
|
||||||
|
*/
|
||||||
const ACTION_VERBS = [
|
const ACTION_VERBS = [
|
||||||
'Analyzing',
|
'Analyzing',
|
||||||
'Analyzed',
|
'Analyzed',
|
||||||
@@ -579,8 +552,7 @@ const ACTION_VERBS = [
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Splits text into action verb and remainder for two-tone rendering.
|
* Splits text into action verb and remainder for two-tone rendering.
|
||||||
* @param text - The text to split
|
* Returns [actionVerb, remainder] or [null, text] if no match.
|
||||||
* @returns Tuple of [actionVerb, remainder] or [null, text] if no match
|
|
||||||
*/
|
*/
|
||||||
function splitActionVerb(text: string): [string | null, string] {
|
function splitActionVerb(text: string): [string | null, string] {
|
||||||
for (const verb of ACTION_VERBS) {
|
for (const verb of ACTION_VERBS) {
|
||||||
@@ -600,9 +572,10 @@ function splitActionVerb(text: string): [string | null, string] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders text with a shimmer overlay animation when active.
|
* Renders text with a subtle white shimmer overlay when active, creating a skeleton-like
|
||||||
* Special tools use a gradient color; normal tools highlight action verbs.
|
* loading effect that passes over the existing words without replacing them.
|
||||||
* Uses CSS truncation to clamp to one line with ellipsis.
|
* For special tool calls, uses a gradient color. For normal tools, highlights action verbs
|
||||||
|
* in a lighter color with the rest in default gray.
|
||||||
*/
|
*/
|
||||||
const ShimmerOverlayText = memo(function ShimmerOverlayText({
|
const ShimmerOverlayText = memo(function ShimmerOverlayText({
|
||||||
text,
|
text,
|
||||||
@@ -612,13 +585,10 @@ const ShimmerOverlayText = memo(function ShimmerOverlayText({
|
|||||||
}: ShimmerOverlayTextProps) {
|
}: ShimmerOverlayTextProps) {
|
||||||
const [actionVerb, remainder] = splitActionVerb(text)
|
const [actionVerb, remainder] = splitActionVerb(text)
|
||||||
|
|
||||||
// Base classes for single-line truncation with ellipsis
|
|
||||||
const truncateClasses = 'block w-full overflow-hidden text-ellipsis whitespace-nowrap'
|
|
||||||
|
|
||||||
// Special tools: use tertiary-2 color for entire text with shimmer
|
// Special tools: use tertiary-2 color for entire text with shimmer
|
||||||
if (isSpecial) {
|
if (isSpecial) {
|
||||||
return (
|
return (
|
||||||
<span className={`relative ${truncateClasses} ${className || ''}`}>
|
<span className={`relative inline-block ${className || ''}`}>
|
||||||
<span className='text-[var(--brand-tertiary-2)]'>{text}</span>
|
<span className='text-[var(--brand-tertiary-2)]'>{text}</span>
|
||||||
{active ? (
|
{active ? (
|
||||||
<span
|
<span
|
||||||
@@ -626,7 +596,7 @@ const ShimmerOverlayText = memo(function ShimmerOverlayText({
|
|||||||
className='pointer-events-none absolute inset-0 select-none overflow-hidden'
|
className='pointer-events-none absolute inset-0 select-none overflow-hidden'
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className='block overflow-hidden text-ellipsis whitespace-nowrap text-transparent'
|
className='block text-transparent'
|
||||||
style={{
|
style={{
|
||||||
backgroundImage:
|
backgroundImage:
|
||||||
'linear-gradient(90deg, rgba(51,196,129,0) 0%, rgba(255,255,255,0.6) 50%, rgba(51,196,129,0) 100%)',
|
'linear-gradient(90deg, rgba(51,196,129,0) 0%, rgba(255,255,255,0.6) 50%, rgba(51,196,129,0) 100%)',
|
||||||
@@ -657,7 +627,7 @@ const ShimmerOverlayText = memo(function ShimmerOverlayText({
|
|||||||
// Light mode: primary (#2d2d2d) vs muted (#737373) for good contrast
|
// Light mode: primary (#2d2d2d) vs muted (#737373) for good contrast
|
||||||
// Dark mode: tertiary (#b3b3b3) vs muted (#787878) for good contrast
|
// Dark mode: tertiary (#b3b3b3) vs muted (#787878) for good contrast
|
||||||
return (
|
return (
|
||||||
<span className={`relative ${truncateClasses} ${className || ''}`}>
|
<span className={`relative inline-block ${className || ''}`}>
|
||||||
{actionVerb ? (
|
{actionVerb ? (
|
||||||
<>
|
<>
|
||||||
<span className='text-[var(--text-primary)] dark:text-[var(--text-tertiary)]'>
|
<span className='text-[var(--text-primary)] dark:text-[var(--text-tertiary)]'>
|
||||||
@@ -674,7 +644,7 @@ const ShimmerOverlayText = memo(function ShimmerOverlayText({
|
|||||||
className='pointer-events-none absolute inset-0 select-none overflow-hidden'
|
className='pointer-events-none absolute inset-0 select-none overflow-hidden'
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className='block overflow-hidden text-ellipsis whitespace-nowrap text-transparent'
|
className='block text-transparent'
|
||||||
style={{
|
style={{
|
||||||
backgroundImage:
|
backgroundImage:
|
||||||
'linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.85) 50%, rgba(255,255,255,0) 100%)',
|
'linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.85) 50%, rgba(255,255,255,0) 100%)',
|
||||||
@@ -702,9 +672,8 @@ const ShimmerOverlayText = memo(function ShimmerOverlayText({
|
|||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the collapse header label for completed subagent tools.
|
* Get the outer collapse header label for completed subagent tools.
|
||||||
* @param toolName - The tool name to get the label for
|
* Uses the tool's UI config.
|
||||||
* @returns The completion label from UI config, defaults to 'Thought'
|
|
||||||
*/
|
*/
|
||||||
function getSubagentCompletionLabel(toolName: string): string {
|
function getSubagentCompletionLabel(toolName: string): string {
|
||||||
const labels = getSubagentLabelsFromConfig(toolName, false)
|
const labels = getSubagentLabelsFromConfig(toolName, false)
|
||||||
@@ -712,9 +681,8 @@ function getSubagentCompletionLabel(toolName: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders subagent blocks as thinking text within regular tool calls.
|
* SubAgentThinkingContent renders subagent blocks as simple thinking text (ThinkingBlock).
|
||||||
* @param blocks - The subagent content blocks to render
|
* Used for inline rendering within regular tool calls that have subagent content.
|
||||||
* @param isStreaming - Whether streaming animations should be shown (caller should pre-compute currentMessage check)
|
|
||||||
*/
|
*/
|
||||||
function SubAgentThinkingContent({
|
function SubAgentThinkingContent({
|
||||||
blocks,
|
blocks,
|
||||||
@@ -749,7 +717,7 @@ function SubAgentThinkingContent({
|
|||||||
const hasSpecialTags = hasPlan
|
const hasSpecialTags = hasPlan
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='space-y-[4px]'>
|
<div className='space-y-1.5'>
|
||||||
{cleanText.trim() && (
|
{cleanText.trim() && (
|
||||||
<ThinkingBlock
|
<ThinkingBlock
|
||||||
content={cleanText}
|
content={cleanText}
|
||||||
@@ -763,29 +731,32 @@ function SubAgentThinkingContent({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Subagents that collapse into summary headers when done streaming */
|
/**
|
||||||
|
* Subagents that should collapse when done streaming.
|
||||||
|
* Default behavior is to NOT collapse (stay expanded like edit, superagent, info, etc.).
|
||||||
|
* Only plan, debug, and research collapse into summary headers.
|
||||||
|
*/
|
||||||
const COLLAPSIBLE_SUBAGENTS = new Set(['plan', 'debug', 'research'])
|
const COLLAPSIBLE_SUBAGENTS = new Set(['plan', 'debug', 'research'])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles rendering of subagent content with streaming and collapse behavior.
|
* SubagentContentRenderer handles the rendering of subagent content.
|
||||||
|
* - During streaming: Shows content at top level
|
||||||
|
* - When done (not streaming): Most subagents stay expanded, only specific ones collapse
|
||||||
|
* - Exception: plan, debug, research, info subagents collapse into a header
|
||||||
*/
|
*/
|
||||||
const SubagentContentRenderer = memo(function SubagentContentRenderer({
|
const SubagentContentRenderer = memo(function SubagentContentRenderer({
|
||||||
toolCall,
|
toolCall,
|
||||||
shouldCollapse,
|
shouldCollapse,
|
||||||
isCurrentMessage = true,
|
|
||||||
}: {
|
}: {
|
||||||
toolCall: CopilotToolCall
|
toolCall: CopilotToolCall
|
||||||
shouldCollapse: boolean
|
shouldCollapse: boolean
|
||||||
/** Whether this is from the current/latest message. Controls shimmer animations. */
|
|
||||||
isCurrentMessage?: boolean
|
|
||||||
}) {
|
}) {
|
||||||
const [isExpanded, setIsExpanded] = useState(true)
|
const [isExpanded, setIsExpanded] = useState(true)
|
||||||
const [duration, setDuration] = useState(0)
|
const [duration, setDuration] = useState(0)
|
||||||
const startTimeRef = useRef<number>(Date.now())
|
const startTimeRef = useRef<number>(Date.now())
|
||||||
const wasStreamingRef = useRef(false)
|
const wasStreamingRef = useRef(false)
|
||||||
|
|
||||||
// Only show streaming animations for current message
|
const isStreaming = !!toolCall.subAgentStreaming
|
||||||
const isStreaming = isCurrentMessage && !!toolCall.subAgentStreaming
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isStreaming && !wasStreamingRef.current) {
|
if (isStreaming && !wasStreamingRef.current) {
|
||||||
@@ -879,11 +850,7 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div key={`tool-${segment.block.toolCall.id || index}`}>
|
<div key={`tool-${segment.block.toolCall.id || index}`}>
|
||||||
<ToolCall
|
<ToolCall toolCallId={segment.block.toolCall.id} toolCall={segment.block.toolCall} />
|
||||||
toolCallId={segment.block.toolCall.id}
|
|
||||||
toolCall={segment.block.toolCall}
|
|
||||||
isCurrentMessage={isCurrentMessage}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -894,7 +861,7 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
|
|||||||
|
|
||||||
if (isStreaming || !shouldCollapse) {
|
if (isStreaming || !shouldCollapse) {
|
||||||
return (
|
return (
|
||||||
<div className='w-full space-y-[4px]'>
|
<div className='w-full space-y-1.5'>
|
||||||
{renderCollapsibleContent()}
|
{renderCollapsibleContent()}
|
||||||
{hasPlan && planToRender && <PlanSteps steps={planToRender} streaming={isPlanStreaming} />}
|
{hasPlan && planToRender && <PlanSteps steps={planToRender} streaming={isPlanStreaming} />}
|
||||||
</div>
|
</div>
|
||||||
@@ -921,30 +888,30 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
|
|||||||
<div
|
<div
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'overflow-hidden transition-all duration-150 ease-out',
|
'overflow-hidden transition-all duration-150 ease-out',
|
||||||
isExpanded ? 'mt-1.5 max-h-[5000px] space-y-[4px] opacity-100' : 'max-h-0 opacity-0'
|
isExpanded ? 'mt-1.5 max-h-[5000px] opacity-100' : 'max-h-0 opacity-0'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{renderCollapsibleContent()}
|
{renderCollapsibleContent()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasPlan && planToRender && (
|
{/* Plan stays outside the collapsible */}
|
||||||
<div className='mt-[6px]'>
|
{hasPlan && planToRender && <PlanSteps steps={planToRender} />}
|
||||||
<PlanSteps steps={planToRender} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determines if a tool call should display with special gradient styling.
|
* Determines if a tool call is "special" and should display with gradient styling.
|
||||||
|
* Uses the tool's UI config.
|
||||||
*/
|
*/
|
||||||
function isSpecialToolCall(toolCall: CopilotToolCall): boolean {
|
function isSpecialToolCall(toolCall: CopilotToolCall): boolean {
|
||||||
return isSpecialToolFromConfig(toolCall.name)
|
return isSpecialToolFromConfig(toolCall.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Displays a summary of workflow edits with added, edited, and deleted blocks.
|
* WorkflowEditSummary shows a full-width summary of workflow edits (like Cursor's diff).
|
||||||
|
* Displays: workflow name with stats (+N green, N orange, -N red)
|
||||||
|
* Expands inline on click to show individual blocks with their icons.
|
||||||
*/
|
*/
|
||||||
const WorkflowEditSummary = memo(function WorkflowEditSummary({
|
const WorkflowEditSummary = memo(function WorkflowEditSummary({
|
||||||
toolCall,
|
toolCall,
|
||||||
@@ -1202,7 +1169,9 @@ const WorkflowEditSummary = memo(function WorkflowEditSummary({
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
/** Checks if a tool is server-side executed (not a client tool) */
|
/**
|
||||||
|
* Checks if a tool is an integration tool (server-side executed, not a client tool)
|
||||||
|
*/
|
||||||
function isIntegrationTool(toolName: string): boolean {
|
function isIntegrationTool(toolName: string): boolean {
|
||||||
return !CLASS_TOOL_METADATA[toolName]
|
return !CLASS_TOOL_METADATA[toolName]
|
||||||
}
|
}
|
||||||
@@ -1348,7 +1317,9 @@ function getDisplayName(toolCall: CopilotToolCall): string {
|
|||||||
return `${stateVerb} ${formattedName}`
|
return `${stateVerb} ${formattedName}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Gets verb prefix based on tool call state */
|
/**
|
||||||
|
* Get verb prefix based on tool state
|
||||||
|
*/
|
||||||
function getStateVerb(state: string): string {
|
function getStateVerb(state: string): string {
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case 'pending':
|
case 'pending':
|
||||||
@@ -1367,7 +1338,8 @@ function getStateVerb(state: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formats tool name for display (e.g., "google_calendar_list_events" -> "Google Calendar List Events")
|
* Format tool name for display
|
||||||
|
* e.g., "google_calendar_list_events" -> "Google Calendar List Events"
|
||||||
*/
|
*/
|
||||||
function formatToolName(name: string): string {
|
function formatToolName(name: string): string {
|
||||||
const baseName = name.replace(/_v\d+$/, '')
|
const baseName = name.replace(/_v\d+$/, '')
|
||||||
@@ -1443,7 +1415,7 @@ function RunSkipButtons({
|
|||||||
|
|
||||||
// Standardized buttons for all interrupt tools: Allow, (Always Allow for client tools only), Skip
|
// Standardized buttons for all interrupt tools: Allow, (Always Allow for client tools only), Skip
|
||||||
return (
|
return (
|
||||||
<div className='mt-[10px] flex gap-[6px]'>
|
<div className='mt-1.5 flex gap-[6px]'>
|
||||||
<Button onClick={onRun} disabled={isProcessing} variant='tertiary'>
|
<Button onClick={onRun} disabled={isProcessing} variant='tertiary'>
|
||||||
{isProcessing ? 'Allowing...' : 'Allow'}
|
{isProcessing ? 'Allowing...' : 'Allow'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1459,12 +1431,7 @@ function RunSkipButtons({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ToolCall({
|
export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }: ToolCallProps) {
|
||||||
toolCall: toolCallProp,
|
|
||||||
toolCallId,
|
|
||||||
onStateChange,
|
|
||||||
isCurrentMessage = true,
|
|
||||||
}: ToolCallProps) {
|
|
||||||
const [, forceUpdate] = useState({})
|
const [, forceUpdate] = useState({})
|
||||||
// Get live toolCall from store to ensure we have the latest state
|
// Get live toolCall from store to ensure we have the latest state
|
||||||
const effectiveId = toolCallId || toolCallProp?.id
|
const effectiveId = toolCallId || toolCallProp?.id
|
||||||
@@ -1478,7 +1445,9 @@ export function ToolCall({
|
|||||||
|
|
||||||
const isExpandablePending =
|
const isExpandablePending =
|
||||||
toolCall?.state === 'pending' &&
|
toolCall?.state === 'pending' &&
|
||||||
(toolCall.name === 'make_api_request' || toolCall.name === 'set_global_workflow_variables')
|
(toolCall.name === 'make_api_request' ||
|
||||||
|
toolCall.name === 'set_global_workflow_variables' ||
|
||||||
|
toolCall.name === 'run_workflow')
|
||||||
|
|
||||||
const [expanded, setExpanded] = useState(isExpandablePending)
|
const [expanded, setExpanded] = useState(isExpandablePending)
|
||||||
const [showRemoveAutoAllow, setShowRemoveAutoAllow] = useState(false)
|
const [showRemoveAutoAllow, setShowRemoveAutoAllow] = useState(false)
|
||||||
@@ -1553,7 +1522,6 @@ export function ToolCall({
|
|||||||
<SubagentContentRenderer
|
<SubagentContentRenderer
|
||||||
toolCall={toolCall}
|
toolCall={toolCall}
|
||||||
shouldCollapse={COLLAPSIBLE_SUBAGENTS.has(toolCall.name)}
|
shouldCollapse={COLLAPSIBLE_SUBAGENTS.has(toolCall.name)}
|
||||||
isCurrentMessage={isCurrentMessage}
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1582,34 +1550,37 @@ export function ToolCall({
|
|||||||
}
|
}
|
||||||
// Check if tool has params table config (meaning it's expandable)
|
// Check if tool has params table config (meaning it's expandable)
|
||||||
const hasParamsTable = !!getToolUIConfig(toolCall.name)?.paramsTable
|
const hasParamsTable = !!getToolUIConfig(toolCall.name)?.paramsTable
|
||||||
const isRunWorkflow = toolCall.name === 'run_workflow'
|
|
||||||
const isExpandableTool =
|
const isExpandableTool =
|
||||||
hasParamsTable ||
|
hasParamsTable ||
|
||||||
toolCall.name === 'make_api_request' ||
|
toolCall.name === 'make_api_request' ||
|
||||||
toolCall.name === 'set_global_workflow_variables'
|
toolCall.name === 'set_global_workflow_variables' ||
|
||||||
|
toolCall.name === 'run_workflow'
|
||||||
|
|
||||||
const showButtons = isCurrentMessage && shouldShowRunSkipButtons(toolCall)
|
const showButtons = shouldShowRunSkipButtons(toolCall)
|
||||||
|
|
||||||
// Check UI config for secondary action - only show for current message tool calls
|
// Check UI config for secondary action
|
||||||
const toolUIConfig = getToolUIConfig(toolCall.name)
|
const toolUIConfig = getToolUIConfig(toolCall.name)
|
||||||
const secondaryAction = toolUIConfig?.secondaryAction
|
const secondaryAction = toolUIConfig?.secondaryAction
|
||||||
const showSecondaryAction = secondaryAction?.showInStates.includes(
|
const showSecondaryAction = secondaryAction?.showInStates.includes(
|
||||||
toolCall.state as ClientToolCallState
|
toolCall.state as ClientToolCallState
|
||||||
)
|
)
|
||||||
const isExecuting =
|
|
||||||
toolCall.state === (ClientToolCallState.executing as any) ||
|
|
||||||
toolCall.state === ('executing' as any)
|
|
||||||
|
|
||||||
// Legacy fallbacks for tools that haven't migrated to UI config
|
// Legacy fallbacks for tools that haven't migrated to UI config
|
||||||
const showMoveToBackground =
|
const showMoveToBackground =
|
||||||
isCurrentMessage &&
|
showSecondaryAction && secondaryAction?.text === 'Move to Background'
|
||||||
((showSecondaryAction && secondaryAction?.text === 'Move to Background') ||
|
? true
|
||||||
(!secondaryAction && toolCall.name === 'run_workflow' && isExecuting))
|
: !secondaryAction &&
|
||||||
|
toolCall.name === 'run_workflow' &&
|
||||||
|
(toolCall.state === (ClientToolCallState.executing as any) ||
|
||||||
|
toolCall.state === ('executing' as any))
|
||||||
|
|
||||||
const showWake =
|
const showWake =
|
||||||
isCurrentMessage &&
|
showSecondaryAction && secondaryAction?.text === 'Wake'
|
||||||
((showSecondaryAction && secondaryAction?.text === 'Wake') ||
|
? true
|
||||||
(!secondaryAction && toolCall.name === 'sleep' && isExecuting))
|
: !secondaryAction &&
|
||||||
|
toolCall.name === 'sleep' &&
|
||||||
|
(toolCall.state === (ClientToolCallState.executing as any) ||
|
||||||
|
toolCall.state === ('executing' as any))
|
||||||
|
|
||||||
const handleStateChange = (state: any) => {
|
const handleStateChange = (state: any) => {
|
||||||
forceUpdate({})
|
forceUpdate({})
|
||||||
@@ -1623,8 +1594,6 @@ export function ToolCall({
|
|||||||
toolCall.state === ClientToolCallState.pending ||
|
toolCall.state === ClientToolCallState.pending ||
|
||||||
toolCall.state === ClientToolCallState.executing
|
toolCall.state === ClientToolCallState.executing
|
||||||
|
|
||||||
const shouldShowShimmer = isCurrentMessage && isLoadingState
|
|
||||||
|
|
||||||
const isSpecial = isSpecialToolCall(toolCall)
|
const isSpecial = isSpecialToolCall(toolCall)
|
||||||
|
|
||||||
const renderPendingDetails = () => {
|
const renderPendingDetails = () => {
|
||||||
@@ -1934,7 +1903,7 @@ export function ToolCall({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/* Input entries */}
|
{/* Input entries */}
|
||||||
<div className='flex flex-col pt-[6px]'>
|
<div className='flex flex-col'>
|
||||||
{inputEntries.map(([key, value], index) => {
|
{inputEntries.map(([key, value], index) => {
|
||||||
const isComplex = isComplexValue(value)
|
const isComplex = isComplexValue(value)
|
||||||
const displayValue = formatValueForDisplay(value)
|
const displayValue = formatValueForDisplay(value)
|
||||||
@@ -1943,8 +1912,8 @@ export function ToolCall({
|
|||||||
<div
|
<div
|
||||||
key={key}
|
key={key}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'flex flex-col gap-[6px] px-[10px] pb-[6px]',
|
'flex flex-col gap-1.5 px-[10px] py-[8px]',
|
||||||
index > 0 && 'mt-[6px] border-[var(--border-1)] border-t pt-[6px]'
|
index > 0 && 'border-[var(--border-1)] border-t'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Input key */}
|
{/* Input key */}
|
||||||
@@ -2036,14 +2005,14 @@ export function ToolCall({
|
|||||||
<div className={isEnvVarsClickable ? 'cursor-pointer' : ''} onClick={handleEnvVarsClick}>
|
<div className={isEnvVarsClickable ? 'cursor-pointer' : ''} onClick={handleEnvVarsClick}>
|
||||||
<ShimmerOverlayText
|
<ShimmerOverlayText
|
||||||
text={displayName}
|
text={displayName}
|
||||||
active={shouldShowShimmer}
|
active={isLoadingState}
|
||||||
isSpecial={isSpecial}
|
isSpecial={isSpecial}
|
||||||
className='font-[470] font-season text-[var(--text-secondary)] text-sm dark:text-[var(--text-muted)]'
|
className='font-[470] font-season text-[var(--text-secondary)] text-sm dark:text-[var(--text-muted)]'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className='mt-[10px]'>{renderPendingDetails()}</div>
|
<div className='mt-1.5'>{renderPendingDetails()}</div>
|
||||||
{showRemoveAutoAllow && isAutoAllowed && (
|
{showRemoveAutoAllow && isAutoAllowed && (
|
||||||
<div className='mt-[10px]'>
|
<div className='mt-1.5'>
|
||||||
<Button
|
<Button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await removeAutoAllowedTool(toolCall.name)
|
await removeAutoAllowedTool(toolCall.name)
|
||||||
@@ -2068,7 +2037,7 @@ export function ToolCall({
|
|||||||
{toolCall.subAgentBlocks && toolCall.subAgentBlocks.length > 0 && (
|
{toolCall.subAgentBlocks && toolCall.subAgentBlocks.length > 0 && (
|
||||||
<SubAgentThinkingContent
|
<SubAgentThinkingContent
|
||||||
blocks={toolCall.subAgentBlocks}
|
blocks={toolCall.subAgentBlocks}
|
||||||
isStreaming={isCurrentMessage && toolCall.subAgentStreaming}
|
isStreaming={toolCall.subAgentStreaming}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -2093,18 +2062,18 @@ export function ToolCall({
|
|||||||
>
|
>
|
||||||
<ShimmerOverlayText
|
<ShimmerOverlayText
|
||||||
text={displayName}
|
text={displayName}
|
||||||
active={shouldShowShimmer}
|
active={isLoadingState}
|
||||||
isSpecial={isSpecial}
|
isSpecial={isSpecial}
|
||||||
className='font-[470] font-season text-[var(--text-secondary)] text-sm dark:text-[var(--text-muted)]'
|
className='font-[470] font-season text-[var(--text-secondary)] text-sm dark:text-[var(--text-muted)]'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{code && (
|
{code && (
|
||||||
<div className='mt-[10px]'>
|
<div className='mt-1.5'>
|
||||||
<Code.Viewer code={code} language='javascript' showGutter className='min-h-0' />
|
<Code.Viewer code={code} language='javascript' showGutter className='min-h-0' />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showRemoveAutoAllow && isAutoAllowed && (
|
{showRemoveAutoAllow && isAutoAllowed && (
|
||||||
<div className='mt-[10px]'>
|
<div className='mt-1.5'>
|
||||||
<Button
|
<Button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await removeAutoAllowedTool(toolCall.name)
|
await removeAutoAllowedTool(toolCall.name)
|
||||||
@@ -2129,14 +2098,14 @@ export function ToolCall({
|
|||||||
{toolCall.subAgentBlocks && toolCall.subAgentBlocks.length > 0 && (
|
{toolCall.subAgentBlocks && toolCall.subAgentBlocks.length > 0 && (
|
||||||
<SubAgentThinkingContent
|
<SubAgentThinkingContent
|
||||||
blocks={toolCall.subAgentBlocks}
|
blocks={toolCall.subAgentBlocks}
|
||||||
isStreaming={isCurrentMessage && toolCall.subAgentStreaming}
|
isStreaming={toolCall.subAgentStreaming}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const isToolNameClickable = (!isRunWorkflow && isExpandableTool) || isAutoAllowed
|
const isToolNameClickable = isExpandableTool || isAutoAllowed
|
||||||
|
|
||||||
const handleToolNameClick = () => {
|
const handleToolNameClick = () => {
|
||||||
if (isExpandableTool) {
|
if (isExpandableTool) {
|
||||||
@@ -2147,7 +2116,6 @@ export function ToolCall({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isEditWorkflow = toolCall.name === 'edit_workflow'
|
const isEditWorkflow = toolCall.name === 'edit_workflow'
|
||||||
const shouldShowDetails = isRunWorkflow || (isExpandableTool && expanded)
|
|
||||||
const hasOperations = Array.isArray(params.operations) && params.operations.length > 0
|
const hasOperations = Array.isArray(params.operations) && params.operations.length > 0
|
||||||
const hideTextForEditWorkflow = isEditWorkflow && hasOperations
|
const hideTextForEditWorkflow = isEditWorkflow && hasOperations
|
||||||
|
|
||||||
@@ -2157,15 +2125,15 @@ export function ToolCall({
|
|||||||
<div className={isToolNameClickable ? 'cursor-pointer' : ''} onClick={handleToolNameClick}>
|
<div className={isToolNameClickable ? 'cursor-pointer' : ''} onClick={handleToolNameClick}>
|
||||||
<ShimmerOverlayText
|
<ShimmerOverlayText
|
||||||
text={displayName}
|
text={displayName}
|
||||||
active={shouldShowShimmer}
|
active={isLoadingState}
|
||||||
isSpecial={isSpecial}
|
isSpecial={isSpecial}
|
||||||
className='font-[470] font-season text-[var(--text-secondary)] text-sm dark:text-[var(--text-muted)]'
|
className='font-[470] font-season text-[var(--text-secondary)] text-sm dark:text-[var(--text-muted)]'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{shouldShowDetails && <div className='mt-[10px]'>{renderPendingDetails()}</div>}
|
{isExpandableTool && expanded && <div className='mt-1.5'>{renderPendingDetails()}</div>}
|
||||||
{showRemoveAutoAllow && isAutoAllowed && (
|
{showRemoveAutoAllow && isAutoAllowed && (
|
||||||
<div className='mt-[10px]'>
|
<div className='mt-1.5'>
|
||||||
<Button
|
<Button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await removeAutoAllowedTool(toolCall.name)
|
await removeAutoAllowedTool(toolCall.name)
|
||||||
@@ -2186,7 +2154,7 @@ export function ToolCall({
|
|||||||
editedParams={editedParams}
|
editedParams={editedParams}
|
||||||
/>
|
/>
|
||||||
) : showMoveToBackground ? (
|
) : showMoveToBackground ? (
|
||||||
<div className='mt-[10px]'>
|
<div className='mt-1.5'>
|
||||||
<Button
|
<Button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
try {
|
||||||
@@ -2207,7 +2175,7 @@ export function ToolCall({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : showWake ? (
|
) : showWake ? (
|
||||||
<div className='mt-[10px]'>
|
<div className='mt-1.5'>
|
||||||
<Button
|
<Button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
try {
|
||||||
@@ -2240,7 +2208,7 @@ export function ToolCall({
|
|||||||
{toolCall.subAgentBlocks && toolCall.subAgentBlocks.length > 0 && (
|
{toolCall.subAgentBlocks && toolCall.subAgentBlocks.length > 0 && (
|
||||||
<SubAgentThinkingContent
|
<SubAgentThinkingContent
|
||||||
blocks={toolCall.subAgentBlocks}
|
blocks={toolCall.subAgentBlocks}
|
||||||
isStreaming={isCurrentMessage && toolCall.subAgentStreaming}
|
isStreaming={toolCall.subAgentStreaming}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export * from './attached-files-display'
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { ArrowUp, Image, Loader2 } from 'lucide-react'
|
|
||||||
import { Badge, Button } from '@/components/emcn'
|
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
|
||||||
import { ModeSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/mode-selector/mode-selector'
|
|
||||||
import { ModelSelector } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components/model-selector/model-selector'
|
|
||||||
|
|
||||||
interface BottomControlsProps {
|
|
||||||
mode: 'ask' | 'build' | 'plan'
|
|
||||||
onModeChange?: (mode: 'ask' | 'build' | 'plan') => void
|
|
||||||
selectedModel: string
|
|
||||||
onModelSelect: (model: string) => void
|
|
||||||
isNearTop: boolean
|
|
||||||
disabled: boolean
|
|
||||||
hideModeSelector: boolean
|
|
||||||
canSubmit: boolean
|
|
||||||
isLoading: boolean
|
|
||||||
isAborting: boolean
|
|
||||||
showAbortButton: boolean
|
|
||||||
onSubmit: () => void
|
|
||||||
onAbort: () => void
|
|
||||||
onFileSelect: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Bottom controls section of the user input
|
|
||||||
* Contains mode selector, model selector, file attachment button, and submit/abort buttons
|
|
||||||
*/
|
|
||||||
export function BottomControls({
|
|
||||||
mode,
|
|
||||||
onModeChange,
|
|
||||||
selectedModel,
|
|
||||||
onModelSelect,
|
|
||||||
isNearTop,
|
|
||||||
disabled,
|
|
||||||
hideModeSelector,
|
|
||||||
canSubmit,
|
|
||||||
isLoading,
|
|
||||||
isAborting,
|
|
||||||
showAbortButton,
|
|
||||||
onSubmit,
|
|
||||||
onAbort,
|
|
||||||
onFileSelect,
|
|
||||||
}: BottomControlsProps) {
|
|
||||||
return (
|
|
||||||
<div className='flex items-center justify-between gap-2'>
|
|
||||||
{/* Left side: Mode Selector + Model Selector */}
|
|
||||||
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
|
|
||||||
{!hideModeSelector && (
|
|
||||||
<ModeSelector
|
|
||||||
mode={mode}
|
|
||||||
onModeChange={onModeChange}
|
|
||||||
isNearTop={isNearTop}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ModelSelector
|
|
||||||
selectedModel={selectedModel}
|
|
||||||
isNearTop={isNearTop}
|
|
||||||
onModelSelect={onModelSelect}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right side: Attach Button + Send Button */}
|
|
||||||
<div className='flex flex-shrink-0 items-center gap-[10px]'>
|
|
||||||
<Badge
|
|
||||||
onClick={onFileSelect}
|
|
||||||
title='Attach file'
|
|
||||||
className={cn(
|
|
||||||
'cursor-pointer rounded-[6px] border-0 bg-transparent p-[0px] dark:bg-transparent',
|
|
||||||
disabled && 'cursor-not-allowed opacity-50'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Image className='!h-3.5 !w-3.5 scale-x-110' />
|
|
||||||
</Badge>
|
|
||||||
|
|
||||||
{showAbortButton ? (
|
|
||||||
<Button
|
|
||||||
onClick={onAbort}
|
|
||||||
disabled={isAborting}
|
|
||||||
className={cn(
|
|
||||||
'h-[20px] w-[20px] rounded-full border-0 p-0 transition-colors',
|
|
||||||
!isAborting
|
|
||||||
? 'bg-[var(--c-383838)] hover:bg-[var(--c-575757)] dark:bg-[var(--c-E0E0E0)] dark:hover:bg-[var(--c-CFCFCF)]'
|
|
||||||
: 'bg-[var(--c-383838)] dark:bg-[var(--c-E0E0E0)]'
|
|
||||||
)}
|
|
||||||
title='Stop generation'
|
|
||||||
>
|
|
||||||
{isAborting ? (
|
|
||||||
<Loader2 className='block h-[13px] w-[13px] animate-spin text-white dark:text-black' />
|
|
||||||
) : (
|
|
||||||
<svg
|
|
||||||
className='block h-[13px] w-[13px] fill-white dark:fill-black'
|
|
||||||
viewBox='0 0 24 24'
|
|
||||||
xmlns='http://www.w3.org/2000/svg'
|
|
||||||
>
|
|
||||||
<rect x='4' y='4' width='16' height='16' rx='3' ry='3' />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
onClick={onSubmit}
|
|
||||||
disabled={!canSubmit}
|
|
||||||
className={cn(
|
|
||||||
'h-[22px] w-[22px] rounded-full border-0 p-0 transition-colors',
|
|
||||||
canSubmit
|
|
||||||
? 'bg-[var(--c-383838)] hover:bg-[var(--c-575757)] dark:bg-[var(--c-E0E0E0)] dark:hover:bg-[var(--c-CFCFCF)]'
|
|
||||||
: 'bg-[var(--c-808080)] dark:bg-[var(--c-808080)]'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<Loader2 className='block h-3.5 w-3.5 animate-spin text-white dark:text-black' />
|
|
||||||
) : (
|
|
||||||
<ArrowUp
|
|
||||||
className='block h-3.5 w-3.5 text-white dark:text-black'
|
|
||||||
strokeWidth={2.25}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from './bottom-controls'
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from './context-pills'
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
export { AttachedFilesDisplay } from './attached-files-display'
|
export { AttachedFilesDisplay } from './attached-files-display/attached-files-display'
|
||||||
export { BottomControls } from './bottom-controls'
|
export { ContextPills } from './context-pills/context-pills'
|
||||||
export { ContextPills } from './context-pills'
|
export { type MentionFolderNav, MentionMenu } from './mention-menu/mention-menu'
|
||||||
export { type MentionFolderNav, MentionMenu } from './mention-menu'
|
export { ModeSelector } from './mode-selector/mode-selector'
|
||||||
export { ModeSelector } from './mode-selector'
|
export { ModelSelector } from './model-selector/model-selector'
|
||||||
export { ModelSelector } from './model-selector'
|
export { type SlashFolderNav, SlashMenu } from './slash-menu/slash-menu'
|
||||||
export { type SlashFolderNav, SlashMenu } from './slash-menu'
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export * from './mention-menu'
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from './mode-selector'
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from './model-selector'
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from './slash-menu'
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
escapeRegex,
|
|
||||||
filterOutContext,
|
filterOutContext,
|
||||||
isContextAlreadySelected,
|
isContextAlreadySelected,
|
||||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
|
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
|
||||||
@@ -23,6 +22,9 @@ interface UseContextManagementProps {
|
|||||||
export function useContextManagement({ message, initialContexts }: UseContextManagementProps) {
|
export function useContextManagement({ message, initialContexts }: UseContextManagementProps) {
|
||||||
const [selectedContexts, setSelectedContexts] = useState<ChatContext[]>(initialContexts ?? [])
|
const [selectedContexts, setSelectedContexts] = useState<ChatContext[]>(initialContexts ?? [])
|
||||||
const initializedRef = useRef(false)
|
const initializedRef = useRef(false)
|
||||||
|
const escapeRegex = useCallback((value: string) => {
|
||||||
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Initialize with initial contexts when they're first provided (for edit mode)
|
// Initialize with initial contexts when they're first provided (for edit mode)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export * from './user-input'
|
|
||||||
@@ -9,19 +9,19 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { createLogger } from '@sim/logger'
|
import { createLogger } from '@sim/logger'
|
||||||
import { AtSign } from 'lucide-react'
|
import { ArrowUp, AtSign, Image, Loader2 } from 'lucide-react'
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams } from 'next/navigation'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { Badge, Button, Textarea } from '@/components/emcn'
|
import { Badge, Button, Textarea } from '@/components/emcn'
|
||||||
import { useSession } from '@/lib/auth/auth-client'
|
import { useSession } from '@/lib/auth/auth-client'
|
||||||
import type { CopilotModelId } from '@/lib/copilot/models'
|
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
import {
|
import {
|
||||||
AttachedFilesDisplay,
|
AttachedFilesDisplay,
|
||||||
BottomControls,
|
|
||||||
ContextPills,
|
ContextPills,
|
||||||
type MentionFolderNav,
|
type MentionFolderNav,
|
||||||
MentionMenu,
|
MentionMenu,
|
||||||
|
ModelSelector,
|
||||||
|
ModeSelector,
|
||||||
type SlashFolderNav,
|
type SlashFolderNav,
|
||||||
SlashMenu,
|
SlashMenu,
|
||||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components'
|
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components'
|
||||||
@@ -44,10 +44,6 @@ import {
|
|||||||
useTextareaAutoResize,
|
useTextareaAutoResize,
|
||||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks'
|
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks'
|
||||||
import type { MessageFileAttachment } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments'
|
import type { MessageFileAttachment } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-file-attachments'
|
||||||
import {
|
|
||||||
computeMentionHighlightRanges,
|
|
||||||
extractContextTokens,
|
|
||||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
|
|
||||||
import type { ChatContext } from '@/stores/panel'
|
import type { ChatContext } from '@/stores/panel'
|
||||||
import { useCopilotStore } from '@/stores/panel'
|
import { useCopilotStore } from '@/stores/panel'
|
||||||
|
|
||||||
@@ -267,6 +263,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
|||||||
|
|
||||||
if (q && q.length > 0) {
|
if (q && q.length > 0) {
|
||||||
void mentionData.ensurePastChatsLoaded()
|
void mentionData.ensurePastChatsLoaded()
|
||||||
|
// workflows and workflow-blocks auto-load from stores
|
||||||
void mentionData.ensureKnowledgeLoaded()
|
void mentionData.ensureKnowledgeLoaded()
|
||||||
void mentionData.ensureBlocksLoaded()
|
void mentionData.ensureBlocksLoaded()
|
||||||
void mentionData.ensureTemplatesLoaded()
|
void mentionData.ensureTemplatesLoaded()
|
||||||
@@ -309,7 +306,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
|||||||
size: f.size,
|
size: f.size,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
onSubmit(trimmedMessage, fileAttachmentsForApi, contextManagement.selectedContexts)
|
onSubmit(trimmedMessage, fileAttachmentsForApi, contextManagement.selectedContexts as any)
|
||||||
|
|
||||||
const shouldClearInput = clearOnSubmit && !options.preserveInput && !overrideMessage
|
const shouldClearInput = clearOnSubmit && !options.preserveInput && !overrideMessage
|
||||||
if (shouldClearInput) {
|
if (shouldClearInput) {
|
||||||
@@ -660,7 +657,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
|||||||
|
|
||||||
const handleModelSelect = useCallback(
|
const handleModelSelect = useCallback(
|
||||||
(model: string) => {
|
(model: string) => {
|
||||||
setSelectedModel(model as CopilotModelId)
|
setSelectedModel(model as any)
|
||||||
},
|
},
|
||||||
[setSelectedModel]
|
[setSelectedModel]
|
||||||
)
|
)
|
||||||
@@ -680,17 +677,15 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
|||||||
return <span>{displayText}</span>
|
return <span>{displayText}</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokens = extractContextTokens(contexts)
|
const elements: React.ReactNode[] = []
|
||||||
const ranges = computeMentionHighlightRanges(message, tokens)
|
const ranges = mentionTokensWithContext.computeMentionRanges()
|
||||||
|
|
||||||
if (ranges.length === 0) {
|
if (ranges.length === 0) {
|
||||||
const displayText = message.endsWith('\n') ? `${message}\u200B` : message
|
const displayText = message.endsWith('\n') ? `${message}\u200B` : message
|
||||||
return <span>{displayText}</span>
|
return <span>{displayText}</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
const elements: React.ReactNode[] = []
|
|
||||||
let lastIndex = 0
|
let lastIndex = 0
|
||||||
|
|
||||||
for (let i = 0; i < ranges.length; i++) {
|
for (let i = 0; i < ranges.length; i++) {
|
||||||
const range = ranges[i]
|
const range = ranges[i]
|
||||||
|
|
||||||
@@ -699,12 +694,13 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
|||||||
elements.push(<span key={`text-${i}-${lastIndex}-${range.start}`}>{before}</span>)
|
elements.push(<span key={`text-${i}-${lastIndex}-${range.start}`}>{before}</span>)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const mentionText = message.slice(range.start, range.end)
|
||||||
elements.push(
|
elements.push(
|
||||||
<span
|
<span
|
||||||
key={`mention-${i}-${range.start}-${range.end}`}
|
key={`mention-${i}-${range.start}-${range.end}`}
|
||||||
className='rounded-[4px] bg-[rgba(50,189,126,0.65)] py-[1px]'
|
className='rounded-[4px] bg-[rgba(50,189,126,0.65)] py-[1px]'
|
||||||
>
|
>
|
||||||
{range.token}
|
{mentionText}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
lastIndex = range.end
|
lastIndex = range.end
|
||||||
@@ -717,7 +713,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return elements.length > 0 ? elements : <span>{'\u00A0'}</span>
|
return elements.length > 0 ? elements : <span>{'\u00A0'}</span>
|
||||||
}, [message, contextManagement.selectedContexts])
|
}, [message, contextManagement.selectedContexts, mentionTokensWithContext])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -859,22 +855,87 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom Row: Mode Selector + Model Selector + Attach Button + Send Button */}
|
{/* Bottom Row: Mode Selector + Model Selector + Attach Button + Send Button */}
|
||||||
<BottomControls
|
<div className='flex items-center justify-between gap-2'>
|
||||||
mode={mode}
|
{/* Left side: Mode Selector + Model Selector */}
|
||||||
onModeChange={onModeChange}
|
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
|
||||||
selectedModel={selectedModel}
|
{!hideModeSelector && (
|
||||||
onModelSelect={handleModelSelect}
|
<ModeSelector
|
||||||
isNearTop={isNearTop}
|
mode={mode}
|
||||||
disabled={disabled}
|
onModeChange={onModeChange}
|
||||||
hideModeSelector={hideModeSelector}
|
isNearTop={isNearTop}
|
||||||
canSubmit={canSubmit}
|
disabled={disabled}
|
||||||
isLoading={isLoading}
|
/>
|
||||||
isAborting={isAborting}
|
)}
|
||||||
showAbortButton={Boolean(showAbortButton)}
|
|
||||||
onSubmit={() => void handleSubmit()}
|
<ModelSelector
|
||||||
onAbort={handleAbort}
|
selectedModel={selectedModel}
|
||||||
onFileSelect={fileAttachments.handleFileSelect}
|
isNearTop={isNearTop}
|
||||||
/>
|
onModelSelect={handleModelSelect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side: Attach Button + Send Button */}
|
||||||
|
<div className='flex flex-shrink-0 items-center gap-[10px]'>
|
||||||
|
<Badge
|
||||||
|
onClick={fileAttachments.handleFileSelect}
|
||||||
|
title='Attach file'
|
||||||
|
className={cn(
|
||||||
|
'cursor-pointer rounded-[6px] border-0 bg-transparent p-[0px] dark:bg-transparent',
|
||||||
|
disabled && 'cursor-not-allowed opacity-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Image className='!h-3.5 !w-3.5 scale-x-110' />
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
{showAbortButton ? (
|
||||||
|
<Button
|
||||||
|
onClick={handleAbort}
|
||||||
|
disabled={isAborting}
|
||||||
|
className={cn(
|
||||||
|
'h-[20px] w-[20px] rounded-full border-0 p-0 transition-colors',
|
||||||
|
!isAborting
|
||||||
|
? 'bg-[var(--c-383838)] hover:bg-[var(--c-575757)] dark:bg-[var(--c-E0E0E0)] dark:hover:bg-[var(--c-CFCFCF)]'
|
||||||
|
: 'bg-[var(--c-383838)] dark:bg-[var(--c-E0E0E0)]'
|
||||||
|
)}
|
||||||
|
title='Stop generation'
|
||||||
|
>
|
||||||
|
{isAborting ? (
|
||||||
|
<Loader2 className='block h-[13px] w-[13px] animate-spin text-white dark:text-black' />
|
||||||
|
) : (
|
||||||
|
<svg
|
||||||
|
className='block h-[13px] w-[13px] fill-white dark:fill-black'
|
||||||
|
viewBox='0 0 24 24'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
>
|
||||||
|
<rect x='4' y='4' width='16' height='16' rx='3' ry='3' />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
void handleSubmit()
|
||||||
|
}}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
className={cn(
|
||||||
|
'h-[22px] w-[22px] rounded-full border-0 p-0 transition-colors',
|
||||||
|
canSubmit
|
||||||
|
? 'bg-[var(--c-383838)] hover:bg-[var(--c-575757)] dark:bg-[var(--c-E0E0E0)] dark:hover:bg-[var(--c-CFCFCF)]'
|
||||||
|
: 'bg-[var(--c-808080)] dark:bg-[var(--c-808080)]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Loader2 className='block h-3.5 w-3.5 animate-spin text-white dark:text-black' />
|
||||||
|
) : (
|
||||||
|
<ArrowUp
|
||||||
|
className='block h-3.5 w-3.5 text-white dark:text-black'
|
||||||
|
strokeWidth={2.25}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Hidden File Input - enabled during streaming so users can prepare images for the next message */}
|
{/* Hidden File Input - enabled during streaming so users can prepare images for the next message */}
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import type { ReactNode } from 'react'
|
|
||||||
import {
|
import {
|
||||||
FOLDER_CONFIGS,
|
FOLDER_CONFIGS,
|
||||||
type MentionFolderId,
|
type MentionFolderId,
|
||||||
@@ -6,102 +5,6 @@ import {
|
|||||||
import type { MentionDataReturn } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data'
|
import type { MentionDataReturn } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/hooks/use-mention-data'
|
||||||
import type { ChatContext } from '@/stores/panel'
|
import type { ChatContext } from '@/stores/panel'
|
||||||
|
|
||||||
/**
|
|
||||||
* Escapes special regex characters in a string
|
|
||||||
* @param value - String to escape
|
|
||||||
* @returns Escaped string safe for use in RegExp
|
|
||||||
*/
|
|
||||||
export function escapeRegex(value: string): string {
|
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extracts mention tokens from contexts for display/matching
|
|
||||||
* Filters out current_workflow contexts and builds prefixed labels
|
|
||||||
* @param contexts - Array of chat contexts
|
|
||||||
* @returns Array of prefixed token strings (e.g., "@workflow", "/web")
|
|
||||||
*/
|
|
||||||
export function extractContextTokens(contexts: ChatContext[]): string[] {
|
|
||||||
return contexts
|
|
||||||
.filter((c) => c.kind !== 'current_workflow' && c.label)
|
|
||||||
.map((c) => {
|
|
||||||
const prefix = c.kind === 'slash_command' ? '/' : '@'
|
|
||||||
return `${prefix}${c.label}`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mention range for text highlighting
|
|
||||||
*/
|
|
||||||
export interface MentionHighlightRange {
|
|
||||||
start: number
|
|
||||||
end: number
|
|
||||||
token: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Computes mention ranges in text for highlighting
|
|
||||||
* @param text - Text to search
|
|
||||||
* @param tokens - Prefixed tokens to find (e.g., "@workflow", "/web")
|
|
||||||
* @returns Array of ranges with start, end, and matched token
|
|
||||||
*/
|
|
||||||
export function computeMentionHighlightRanges(
|
|
||||||
text: string,
|
|
||||||
tokens: string[]
|
|
||||||
): MentionHighlightRange[] {
|
|
||||||
if (!tokens.length || !text) return []
|
|
||||||
|
|
||||||
const pattern = new RegExp(`(${tokens.map(escapeRegex).join('|')})`, 'g')
|
|
||||||
const ranges: MentionHighlightRange[] = []
|
|
||||||
let match: RegExpExecArray | null
|
|
||||||
|
|
||||||
while ((match = pattern.exec(text)) !== null) {
|
|
||||||
ranges.push({
|
|
||||||
start: match.index,
|
|
||||||
end: match.index + match[0].length,
|
|
||||||
token: match[0],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return ranges
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds React nodes with highlighted mention tokens
|
|
||||||
* @param text - Text to render
|
|
||||||
* @param contexts - Chat contexts to highlight
|
|
||||||
* @param createHighlightSpan - Function to create highlighted span element
|
|
||||||
* @returns Array of React nodes with highlighted mentions
|
|
||||||
*/
|
|
||||||
export function buildMentionHighlightNodes(
|
|
||||||
text: string,
|
|
||||||
contexts: ChatContext[],
|
|
||||||
createHighlightSpan: (token: string, key: string) => ReactNode
|
|
||||||
): ReactNode[] {
|
|
||||||
const tokens = extractContextTokens(contexts)
|
|
||||||
if (!tokens.length) return [text]
|
|
||||||
|
|
||||||
const ranges = computeMentionHighlightRanges(text, tokens)
|
|
||||||
if (!ranges.length) return [text]
|
|
||||||
|
|
||||||
const nodes: ReactNode[] = []
|
|
||||||
let lastIndex = 0
|
|
||||||
|
|
||||||
for (const range of ranges) {
|
|
||||||
if (range.start > lastIndex) {
|
|
||||||
nodes.push(text.slice(lastIndex, range.start))
|
|
||||||
}
|
|
||||||
nodes.push(createHighlightSpan(range.token, `mention-${range.start}-${range.end}`))
|
|
||||||
lastIndex = range.end
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lastIndex < text.length) {
|
|
||||||
nodes.push(text.slice(lastIndex))
|
|
||||||
}
|
|
||||||
|
|
||||||
return nodes
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the data array for a folder ID from mentionData.
|
* Gets the data array for a folder ID from mentionData.
|
||||||
* Uses FOLDER_CONFIGS as the source of truth for key mapping.
|
* Uses FOLDER_CONFIGS as the source of truth for key mapping.
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export * from './welcome'
|
|
||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
import { Button } from '@/components/emcn'
|
import { Button } from '@/components/emcn'
|
||||||
|
|
||||||
/** Props for the Welcome component */
|
/**
|
||||||
|
* Props for the CopilotWelcome component
|
||||||
|
*/
|
||||||
interface WelcomeProps {
|
interface WelcomeProps {
|
||||||
/** Callback when a suggested question is clicked */
|
/** Callback when a suggested question is clicked */
|
||||||
onQuestionClick?: (question: string) => void
|
onQuestionClick?: (question: string) => void
|
||||||
@@ -10,7 +12,13 @@ interface WelcomeProps {
|
|||||||
mode?: 'ask' | 'build' | 'plan'
|
mode?: 'ask' | 'build' | 'plan'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Welcome screen displaying suggested questions based on current mode */
|
/**
|
||||||
|
* Welcome screen component for the copilot
|
||||||
|
* Displays suggested questions and capabilities based on current mode
|
||||||
|
*
|
||||||
|
* @param props - Component props
|
||||||
|
* @returns Welcome screen UI
|
||||||
|
*/
|
||||||
export function Welcome({ onQuestionClick, mode = 'ask' }: WelcomeProps) {
|
export function Welcome({ onQuestionClick, mode = 'ask' }: WelcomeProps) {
|
||||||
const capabilities =
|
const capabilities =
|
||||||
mode === 'build'
|
mode === 'build'
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import {
|
|||||||
import { Trash } from '@/components/emcn/icons/trash'
|
import { Trash } from '@/components/emcn/icons/trash'
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
import {
|
import {
|
||||||
ChatHistorySkeleton,
|
|
||||||
CopilotMessage,
|
CopilotMessage,
|
||||||
PlanModeSection,
|
PlanModeSection,
|
||||||
QueuedMessages,
|
QueuedMessages,
|
||||||
@@ -41,7 +40,6 @@ import {
|
|||||||
useTodoManagement,
|
useTodoManagement,
|
||||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/hooks'
|
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/hooks'
|
||||||
import { useScrollManagement } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
import { useScrollManagement } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||||
import type { ChatContext } from '@/stores/panel'
|
|
||||||
import { useCopilotStore } from '@/stores/panel'
|
import { useCopilotStore } from '@/stores/panel'
|
||||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||||
|
|
||||||
@@ -76,12 +74,10 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
|||||||
const copilotContainerRef = useRef<HTMLDivElement>(null)
|
const copilotContainerRef = useRef<HTMLDivElement>(null)
|
||||||
const cancelEditCallbackRef = useRef<(() => void) | null>(null)
|
const cancelEditCallbackRef = useRef<(() => void) | null>(null)
|
||||||
const [editingMessageId, setEditingMessageId] = useState<string | null>(null)
|
const [editingMessageId, setEditingMessageId] = useState<string | null>(null)
|
||||||
|
const [isEditingMessage, setIsEditingMessage] = useState(false)
|
||||||
const [revertingMessageId, setRevertingMessageId] = useState<string | null>(null)
|
const [revertingMessageId, setRevertingMessageId] = useState<string | null>(null)
|
||||||
const [isHistoryDropdownOpen, setIsHistoryDropdownOpen] = useState(false)
|
const [isHistoryDropdownOpen, setIsHistoryDropdownOpen] = useState(false)
|
||||||
|
|
||||||
// Derived state - editing when there's an editingMessageId
|
|
||||||
const isEditingMessage = editingMessageId !== null
|
|
||||||
|
|
||||||
const { activeWorkflowId } = useWorkflowRegistry()
|
const { activeWorkflowId } = useWorkflowRegistry()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -110,9 +106,9 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
|||||||
areChatsFresh,
|
areChatsFresh,
|
||||||
workflowId: copilotWorkflowId,
|
workflowId: copilotWorkflowId,
|
||||||
setPlanTodos,
|
setPlanTodos,
|
||||||
closePlanTodos,
|
|
||||||
clearPlanArtifact,
|
clearPlanArtifact,
|
||||||
savePlanArtifact,
|
savePlanArtifact,
|
||||||
|
setSelectedModel,
|
||||||
loadAutoAllowedTools,
|
loadAutoAllowedTools,
|
||||||
} = useCopilotStore()
|
} = useCopilotStore()
|
||||||
|
|
||||||
@@ -130,7 +126,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
|||||||
|
|
||||||
// Handle scroll management (80px stickiness for copilot)
|
// Handle scroll management (80px stickiness for copilot)
|
||||||
const { scrollAreaRef, scrollToBottom } = useScrollManagement(messages, isSendingMessage, {
|
const { scrollAreaRef, scrollToBottom } = useScrollManagement(messages, isSendingMessage, {
|
||||||
stickinessThreshold: 40,
|
stickinessThreshold: 80,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Handle chat history grouping
|
// Handle chat history grouping
|
||||||
@@ -150,10 +146,15 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
|||||||
isSendingMessage,
|
isSendingMessage,
|
||||||
showPlanTodos,
|
showPlanTodos,
|
||||||
planTodos,
|
planTodos,
|
||||||
|
setPlanTodos,
|
||||||
})
|
})
|
||||||
|
|
||||||
/** Gets markdown content for design document section (available in all modes once created) */
|
/**
|
||||||
|
* Get markdown content for design document section
|
||||||
|
* Available in all modes once created
|
||||||
|
*/
|
||||||
const designDocumentContent = useMemo(() => {
|
const designDocumentContent = useMemo(() => {
|
||||||
|
// Use streaming content if available
|
||||||
if (streamingPlanContent) {
|
if (streamingPlanContent) {
|
||||||
logger.info('[DesignDocument] Using streaming plan content', {
|
logger.info('[DesignDocument] Using streaming plan content', {
|
||||||
contentLength: streamingPlanContent.length,
|
contentLength: streamingPlanContent.length,
|
||||||
@@ -164,7 +165,9 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
|||||||
return ''
|
return ''
|
||||||
}, [streamingPlanContent])
|
}, [streamingPlanContent])
|
||||||
|
|
||||||
/** Focuses the copilot input */
|
/**
|
||||||
|
* Helper function to focus the copilot input
|
||||||
|
*/
|
||||||
const focusInput = useCallback(() => {
|
const focusInput = useCallback(() => {
|
||||||
userInputRef.current?.focus()
|
userInputRef.current?.focus()
|
||||||
}, [])
|
}, [])
|
||||||
@@ -178,14 +181,20 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
|||||||
currentInputValue: inputValue,
|
currentInputValue: inputValue,
|
||||||
})
|
})
|
||||||
|
|
||||||
/** Auto-scrolls to bottom when chat loads */
|
/**
|
||||||
|
* Auto-scroll to bottom when chat loads in
|
||||||
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isInitialized && messages.length > 0) {
|
if (isInitialized && messages.length > 0) {
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
}
|
}
|
||||||
}, [isInitialized, messages.length, scrollToBottom])
|
}, [isInitialized, messages.length, scrollToBottom])
|
||||||
|
|
||||||
/** Cleanup on unmount - aborts active streaming. Uses refs to avoid stale closures */
|
/**
|
||||||
|
* Cleanup on component unmount (page refresh, navigation, etc.)
|
||||||
|
* Uses a ref to track sending state to avoid stale closure issues
|
||||||
|
* Note: Parent workflow.tsx also has useStreamCleanup for page-level cleanup
|
||||||
|
*/
|
||||||
const isSendingRef = useRef(isSendingMessage)
|
const isSendingRef = useRef(isSendingMessage)
|
||||||
isSendingRef.current = isSendingMessage
|
isSendingRef.current = isSendingMessage
|
||||||
const abortMessageRef = useRef(abortMessage)
|
const abortMessageRef = useRef(abortMessage)
|
||||||
@@ -193,15 +202,19 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
// Use refs to check current values, not stale closure values
|
||||||
if (isSendingRef.current) {
|
if (isSendingRef.current) {
|
||||||
abortMessageRef.current()
|
abortMessageRef.current()
|
||||||
logger.info('Aborted active message streaming due to component unmount')
|
logger.info('Aborted active message streaming due to component unmount')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Empty deps - only run cleanup on actual unmount, not on re-renders
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
/** Cancels edit mode when clicking outside the current edit area */
|
/**
|
||||||
|
* Container-level click capture to cancel edit mode when clicking outside the current edit area
|
||||||
|
*/
|
||||||
const handleCopilotClickCapture = useCallback(
|
const handleCopilotClickCapture = useCallback(
|
||||||
(event: ReactMouseEvent<HTMLDivElement>) => {
|
(event: ReactMouseEvent<HTMLDivElement>) => {
|
||||||
if (!isEditingMessage) return
|
if (!isEditingMessage) return
|
||||||
@@ -230,7 +243,10 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
|||||||
[isEditingMessage, editingMessageId]
|
[isEditingMessage, editingMessageId]
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Creates a new chat session and focuses the input */
|
/**
|
||||||
|
* Handles creating a new chat session
|
||||||
|
* Focuses the input after creation
|
||||||
|
*/
|
||||||
const handleStartNewChat = useCallback(() => {
|
const handleStartNewChat = useCallback(() => {
|
||||||
createNewChat()
|
createNewChat()
|
||||||
logger.info('Started new chat')
|
logger.info('Started new chat')
|
||||||
@@ -240,7 +256,10 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
|||||||
}, 100)
|
}, 100)
|
||||||
}, [createNewChat])
|
}, [createNewChat])
|
||||||
|
|
||||||
/** Sets the input value and focuses the textarea */
|
/**
|
||||||
|
* Sets the input value and focuses the textarea
|
||||||
|
* @param value - The value to set in the input
|
||||||
|
*/
|
||||||
const handleSetInputValueAndFocus = useCallback(
|
const handleSetInputValueAndFocus = useCallback(
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
setInputValue(value)
|
setInputValue(value)
|
||||||
@@ -251,7 +270,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
|||||||
[setInputValue]
|
[setInputValue]
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Exposes imperative functions to parent */
|
// Expose functions to parent
|
||||||
useImperativeHandle(
|
useImperativeHandle(
|
||||||
ref,
|
ref,
|
||||||
() => ({
|
() => ({
|
||||||
@@ -262,7 +281,10 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
|||||||
[handleStartNewChat, handleSetInputValueAndFocus, focusInput]
|
[handleStartNewChat, handleSetInputValueAndFocus, focusInput]
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Aborts current message streaming and collapses todos if shown */
|
/**
|
||||||
|
* Handles aborting the current message streaming
|
||||||
|
* Collapses todos if they are currently shown
|
||||||
|
*/
|
||||||
const handleAbort = useCallback(() => {
|
const handleAbort = useCallback(() => {
|
||||||
abortMessage()
|
abortMessage()
|
||||||
if (showPlanTodos) {
|
if (showPlanTodos) {
|
||||||
@@ -270,20 +292,20 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
|||||||
}
|
}
|
||||||
}, [abortMessage, showPlanTodos])
|
}, [abortMessage, showPlanTodos])
|
||||||
|
|
||||||
/** Closes the plan todos section and clears the todos */
|
/**
|
||||||
const handleClosePlanTodos = useCallback(() => {
|
* Handles message submission to the copilot
|
||||||
closePlanTodos()
|
* @param query - The message text to send
|
||||||
setPlanTodos([])
|
* @param fileAttachments - Optional file attachments
|
||||||
}, [closePlanTodos, setPlanTodos])
|
* @param contexts - Optional context references
|
||||||
|
*/
|
||||||
/** Handles message submission to the copilot */
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
async (query: string, fileAttachments?: MessageFileAttachment[], contexts?: ChatContext[]) => {
|
async (query: string, fileAttachments?: MessageFileAttachment[], contexts?: any[]) => {
|
||||||
// Allow submission even when isSendingMessage - store will queue the message
|
// Allow submission even when isSendingMessage - store will queue the message
|
||||||
if (!query || !activeWorkflowId) return
|
if (!query || !activeWorkflowId) return
|
||||||
|
|
||||||
if (showPlanTodos) {
|
if (showPlanTodos) {
|
||||||
setPlanTodos([])
|
const store = useCopilotStore.getState()
|
||||||
|
store.setPlanTodos([])
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -297,25 +319,37 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
|||||||
logger.error('Failed to send message:', error)
|
logger.error('Failed to send message:', error)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[activeWorkflowId, sendMessage, showPlanTodos, setPlanTodos]
|
[activeWorkflowId, sendMessage, showPlanTodos]
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Handles message edit mode changes */
|
/**
|
||||||
|
* Handles message edit mode changes
|
||||||
|
* @param messageId - ID of the message being edited
|
||||||
|
* @param isEditing - Whether edit mode is active
|
||||||
|
*/
|
||||||
const handleEditModeChange = useCallback(
|
const handleEditModeChange = useCallback(
|
||||||
(messageId: string, isEditing: boolean, cancelCallback?: () => void) => {
|
(messageId: string, isEditing: boolean, cancelCallback?: () => void) => {
|
||||||
setEditingMessageId(isEditing ? messageId : null)
|
setEditingMessageId(isEditing ? messageId : null)
|
||||||
|
setIsEditingMessage(isEditing)
|
||||||
cancelEditCallbackRef.current = isEditing ? cancelCallback || null : null
|
cancelEditCallbackRef.current = isEditing ? cancelCallback || null : null
|
||||||
logger.info('Edit mode changed', { messageId, isEditing, willDimMessages: isEditing })
|
logger.info('Edit mode changed', { messageId, isEditing, willDimMessages: isEditing })
|
||||||
},
|
},
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Handles checkpoint revert mode changes */
|
/**
|
||||||
|
* Handles checkpoint revert mode changes
|
||||||
|
* @param messageId - ID of the message being reverted
|
||||||
|
* @param isReverting - Whether revert mode is active
|
||||||
|
*/
|
||||||
const handleRevertModeChange = useCallback((messageId: string, isReverting: boolean) => {
|
const handleRevertModeChange = useCallback((messageId: string, isReverting: boolean) => {
|
||||||
setRevertingMessageId(isReverting ? messageId : null)
|
setRevertingMessageId(isReverting ? messageId : null)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
/** Handles chat deletion */
|
/**
|
||||||
|
* Handles chat deletion
|
||||||
|
* @param chatId - ID of the chat to delete
|
||||||
|
*/
|
||||||
const handleDeleteChat = useCallback(
|
const handleDeleteChat = useCallback(
|
||||||
async (chatId: string) => {
|
async (chatId: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -327,15 +361,38 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
|||||||
[deleteChat]
|
[deleteChat]
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Handles history dropdown opening state, loads chats if needed (non-blocking) */
|
/**
|
||||||
|
* Handles history dropdown opening state
|
||||||
|
* Loads chats if needed when dropdown opens (non-blocking)
|
||||||
|
* @param open - Whether the dropdown is open
|
||||||
|
*/
|
||||||
const handleHistoryDropdownOpen = useCallback(
|
const handleHistoryDropdownOpen = useCallback(
|
||||||
(open: boolean) => {
|
(open: boolean) => {
|
||||||
setIsHistoryDropdownOpen(open)
|
setIsHistoryDropdownOpen(open)
|
||||||
|
// Fire hook without awaiting - prevents blocking and state issues
|
||||||
handleHistoryDropdownOpenHook(open)
|
handleHistoryDropdownOpenHook(open)
|
||||||
},
|
},
|
||||||
[handleHistoryDropdownOpenHook]
|
[handleHistoryDropdownOpenHook]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skeleton loading component for chat history
|
||||||
|
*/
|
||||||
|
const ChatHistorySkeleton = () => (
|
||||||
|
<>
|
||||||
|
<PopoverSection>
|
||||||
|
<div className='h-3 w-12 animate-pulse rounded bg-muted/40' />
|
||||||
|
</PopoverSection>
|
||||||
|
<div className='flex flex-col gap-0.5'>
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className='flex h-[25px] items-center px-[6px]'>
|
||||||
|
<div className='h-3 w-full animate-pulse rounded bg-muted/40' />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
@@ -474,18 +531,21 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
|||||||
className='h-full overflow-y-auto overflow-x-hidden px-[8px]'
|
className='h-full overflow-y-auto overflow-x-hidden px-[8px]'
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`w-full max-w-full space-y-[8px] overflow-hidden py-[8px] ${
|
className={`w-full max-w-full space-y-4 overflow-hidden py-[8px] ${
|
||||||
showPlanTodos && planTodos.length > 0 ? 'pb-14' : 'pb-10'
|
showPlanTodos && planTodos.length > 0 ? 'pb-14' : 'pb-10'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{messages.map((message, index) => {
|
{messages.map((message, index) => {
|
||||||
|
// Determine if this message should be dimmed
|
||||||
let isDimmed = false
|
let isDimmed = false
|
||||||
|
|
||||||
|
// Dim messages after the one being edited
|
||||||
if (editingMessageId) {
|
if (editingMessageId) {
|
||||||
const editingIndex = messages.findIndex((m) => m.id === editingMessageId)
|
const editingIndex = messages.findIndex((m) => m.id === editingMessageId)
|
||||||
isDimmed = editingIndex !== -1 && index > editingIndex
|
isDimmed = editingIndex !== -1 && index > editingIndex
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Also dim messages after the one showing restore confirmation
|
||||||
if (!isDimmed && revertingMessageId) {
|
if (!isDimmed && revertingMessageId) {
|
||||||
const revertingIndex = messages.findIndex(
|
const revertingIndex = messages.findIndex(
|
||||||
(m) => m.id === revertingMessageId
|
(m) => m.id === revertingMessageId
|
||||||
@@ -493,6 +553,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
|||||||
isDimmed = revertingIndex !== -1 && index > revertingIndex
|
isDimmed = revertingIndex !== -1 && index > revertingIndex
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get checkpoint count for this message to force re-render when it changes
|
||||||
const checkpointCount = messageCheckpoints[message.id]?.length || 0
|
const checkpointCount = messageCheckpoints[message.id]?.length || 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -527,7 +588,11 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
|||||||
<TodoList
|
<TodoList
|
||||||
todos={planTodos}
|
todos={planTodos}
|
||||||
collapsed={todosCollapsed}
|
collapsed={todosCollapsed}
|
||||||
onClose={handleClosePlanTodos}
|
onClose={() => {
|
||||||
|
const store = useCopilotStore.getState()
|
||||||
|
store.closePlanTodos?.()
|
||||||
|
useCopilotStore.setState({ planTodos: [] })
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ export function useChatHistory(props: UseChatHistoryProps) {
|
|||||||
const { chats, activeWorkflowId, copilotWorkflowId, loadChats, areChatsFresh, isSendingMessage } =
|
const { chats, activeWorkflowId, copilotWorkflowId, loadChats, areChatsFresh, isSendingMessage } =
|
||||||
props
|
props
|
||||||
|
|
||||||
/** Groups chats by time period (Today, Yesterday, This Week, etc.) */
|
/**
|
||||||
|
* Groups chats by time period (Today, Yesterday, This Week, etc.)
|
||||||
|
*/
|
||||||
const groupedChats = useMemo(() => {
|
const groupedChats = useMemo(() => {
|
||||||
if (!activeWorkflowId || copilotWorkflowId !== activeWorkflowId || chats.length === 0) {
|
if (!activeWorkflowId || copilotWorkflowId !== activeWorkflowId || chats.length === 0) {
|
||||||
return []
|
return []
|
||||||
@@ -66,21 +68,18 @@ export function useChatHistory(props: UseChatHistoryProps) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
for (const groupName of Object.keys(groups)) {
|
|
||||||
groups[groupName].sort((a, b) => {
|
|
||||||
const dateA = new Date(a.updatedAt).getTime()
|
|
||||||
const dateB = new Date(b.updatedAt).getTime()
|
|
||||||
return dateB - dateA
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.entries(groups).filter(([, chats]) => chats.length > 0)
|
return Object.entries(groups).filter(([, chats]) => chats.length > 0)
|
||||||
}, [chats, activeWorkflowId, copilotWorkflowId])
|
}, [chats, activeWorkflowId, copilotWorkflowId])
|
||||||
|
|
||||||
/** Handles history dropdown opening and loads chats if needed (non-blocking) */
|
/**
|
||||||
|
* Handles history dropdown opening and loads chats if needed
|
||||||
|
* Does not await loading - fires in background to avoid blocking UI
|
||||||
|
*/
|
||||||
const handleHistoryDropdownOpen = useCallback(
|
const handleHistoryDropdownOpen = useCallback(
|
||||||
(open: boolean) => {
|
(open: boolean) => {
|
||||||
|
// Only load if opening dropdown AND we don't have fresh chats AND not streaming
|
||||||
if (open && activeWorkflowId && !isSendingMessage && !areChatsFresh(activeWorkflowId)) {
|
if (open && activeWorkflowId && !isSendingMessage && !areChatsFresh(activeWorkflowId)) {
|
||||||
|
// Fire in background, don't await - same pattern as old panel
|
||||||
loadChats(false).catch((error) => {
|
loadChats(false).catch((error) => {
|
||||||
logger.error('Failed to load chat history:', error)
|
logger.error('Failed to load chat history:', error)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -38,7 +38,11 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
|
|||||||
const lastWorkflowIdRef = useRef<string | null>(null)
|
const lastWorkflowIdRef = useRef<string | null>(null)
|
||||||
const hasMountedRef = useRef(false)
|
const hasMountedRef = useRef(false)
|
||||||
|
|
||||||
/** Initialize on mount - loads chats if needed. Never loads during streaming */
|
/**
|
||||||
|
* Initialize on mount - only load chats if needed, don't force refresh
|
||||||
|
* This prevents unnecessary reloads when the component remounts (e.g., hot reload)
|
||||||
|
* Never loads during message streaming to prevent interrupting active conversations
|
||||||
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeWorkflowId && !hasMountedRef.current && !isSendingMessage) {
|
if (activeWorkflowId && !hasMountedRef.current && !isSendingMessage) {
|
||||||
hasMountedRef.current = true
|
hasMountedRef.current = true
|
||||||
@@ -46,12 +50,19 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
|
|||||||
lastWorkflowIdRef.current = null
|
lastWorkflowIdRef.current = null
|
||||||
|
|
||||||
setCopilotWorkflowId(activeWorkflowId)
|
setCopilotWorkflowId(activeWorkflowId)
|
||||||
|
// Use false to let the store decide if a reload is needed based on cache
|
||||||
loadChats(false)
|
loadChats(false)
|
||||||
}
|
}
|
||||||
}, [activeWorkflowId, setCopilotWorkflowId, loadChats, isSendingMessage])
|
}, [activeWorkflowId, setCopilotWorkflowId, loadChats, isSendingMessage])
|
||||||
|
|
||||||
/** Handles genuine workflow changes, preventing re-init on every render */
|
/**
|
||||||
|
* Initialize the component - only on mount and genuine workflow changes
|
||||||
|
* Prevents re-initialization on every render or tab switch
|
||||||
|
* Never reloads during message streaming to preserve active conversations
|
||||||
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Handle genuine workflow changes (not initial mount, not same workflow)
|
||||||
|
// Only reload if not currently streaming to avoid interrupting conversations
|
||||||
if (
|
if (
|
||||||
activeWorkflowId &&
|
activeWorkflowId &&
|
||||||
activeWorkflowId !== lastWorkflowIdRef.current &&
|
activeWorkflowId !== lastWorkflowIdRef.current &&
|
||||||
@@ -69,23 +80,7 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
|
|||||||
loadChats(false)
|
loadChats(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
// Mark as initialized when chats are loaded for the active workflow
|
||||||
activeWorkflowId &&
|
|
||||||
!isLoadingChats &&
|
|
||||||
chatsLoadedForWorkflow !== null &&
|
|
||||||
chatsLoadedForWorkflow !== activeWorkflowId &&
|
|
||||||
!isSendingMessage
|
|
||||||
) {
|
|
||||||
logger.info('Chats loaded for wrong workflow, reloading', {
|
|
||||||
loaded: chatsLoadedForWorkflow,
|
|
||||||
active: activeWorkflowId,
|
|
||||||
})
|
|
||||||
setIsInitialized(false)
|
|
||||||
lastWorkflowIdRef.current = activeWorkflowId
|
|
||||||
setCopilotWorkflowId(activeWorkflowId)
|
|
||||||
loadChats(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
activeWorkflowId &&
|
activeWorkflowId &&
|
||||||
!isLoadingChats &&
|
!isLoadingChats &&
|
||||||
@@ -105,7 +100,9 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
|
|||||||
isSendingMessage,
|
isSendingMessage,
|
||||||
])
|
])
|
||||||
|
|
||||||
/** Load auto-allowed tools once on mount */
|
/**
|
||||||
|
* Load auto-allowed tools once on mount
|
||||||
|
*/
|
||||||
const hasLoadedAutoAllowedToolsRef = useRef(false)
|
const hasLoadedAutoAllowedToolsRef = useRef(false)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hasMountedRef.current && !hasLoadedAutoAllowedToolsRef.current) {
|
if (hasMountedRef.current && !hasLoadedAutoAllowedToolsRef.current) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ interface UseTodoManagementProps {
|
|||||||
isSendingMessage: boolean
|
isSendingMessage: boolean
|
||||||
showPlanTodos: boolean
|
showPlanTodos: boolean
|
||||||
planTodos: Array<{ id: string; content: string; completed?: boolean }>
|
planTodos: Array<{ id: string; content: string; completed?: boolean }>
|
||||||
|
setPlanTodos: (todos: any[]) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -15,12 +16,14 @@ interface UseTodoManagementProps {
|
|||||||
* @returns Todo management utilities
|
* @returns Todo management utilities
|
||||||
*/
|
*/
|
||||||
export function useTodoManagement(props: UseTodoManagementProps) {
|
export function useTodoManagement(props: UseTodoManagementProps) {
|
||||||
const { isSendingMessage, showPlanTodos, planTodos } = props
|
const { isSendingMessage, showPlanTodos, planTodos, setPlanTodos } = props
|
||||||
|
|
||||||
const [todosCollapsed, setTodosCollapsed] = useState(false)
|
const [todosCollapsed, setTodosCollapsed] = useState(false)
|
||||||
const wasSendingRef = useRef(false)
|
const wasSendingRef = useRef(false)
|
||||||
|
|
||||||
/** Auto-collapse todos when stream completes */
|
/**
|
||||||
|
* Auto-collapse todos when stream completes. Do not prune items.
|
||||||
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (wasSendingRef.current && !isSendingMessage && showPlanTodos) {
|
if (wasSendingRef.current && !isSendingMessage && showPlanTodos) {
|
||||||
setTodosCollapsed(true)
|
setTodosCollapsed(true)
|
||||||
@@ -28,7 +31,9 @@ export function useTodoManagement(props: UseTodoManagementProps) {
|
|||||||
wasSendingRef.current = isSendingMessage
|
wasSendingRef.current = isSendingMessage
|
||||||
}, [isSendingMessage, showPlanTodos])
|
}, [isSendingMessage, showPlanTodos])
|
||||||
|
|
||||||
/** Reset collapsed state when todos first appear */
|
/**
|
||||||
|
* Reset collapsed state when todos first appear
|
||||||
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showPlanTodos && planTodos.length > 0) {
|
if (showPlanTodos && planTodos.length > 0) {
|
||||||
if (isSendingMessage) {
|
if (isSendingMessage) {
|
||||||
|
|||||||
@@ -452,6 +452,39 @@ console.log(limits);`
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* <div>
|
||||||
|
<div className='mb-[6.5px] flex items-center justify-between'>
|
||||||
|
<Label className='block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
|
||||||
|
URL
|
||||||
|
</Label>
|
||||||
|
<Tooltip.Root>
|
||||||
|
<Tooltip.Trigger asChild>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
onClick={() => handleCopy('endpoint', info.endpoint)}
|
||||||
|
aria-label='Copy endpoint'
|
||||||
|
className='!p-1.5 -my-1.5'
|
||||||
|
>
|
||||||
|
{copied.endpoint ? (
|
||||||
|
<Check className='h-3 w-3' />
|
||||||
|
) : (
|
||||||
|
<Clipboard className='h-3 w-3' />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</Tooltip.Trigger>
|
||||||
|
<Tooltip.Content>
|
||||||
|
<span>{copied.endpoint ? 'Copied' : 'Copy'}</span>
|
||||||
|
</Tooltip.Content>
|
||||||
|
</Tooltip.Root>
|
||||||
|
</div>
|
||||||
|
<Code.Viewer
|
||||||
|
code={info.endpoint}
|
||||||
|
language='javascript'
|
||||||
|
wrapText
|
||||||
|
className='!min-h-0 rounded-[4px] border border-[var(--border-1)]'
|
||||||
|
/>
|
||||||
|
</div> */}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className='mb-[6.5px] flex items-center justify-between'>
|
<div className='mb-[6.5px] flex items-center justify-between'>
|
||||||
<Label className='block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
|
<Label className='block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
|
||||||
|
|||||||
@@ -1,260 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
||||||
import {
|
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
Input,
|
|
||||||
Label,
|
|
||||||
Modal,
|
|
||||||
ModalBody,
|
|
||||||
ModalContent,
|
|
||||||
ModalFooter,
|
|
||||||
ModalHeader,
|
|
||||||
Textarea,
|
|
||||||
} from '@/components/emcn'
|
|
||||||
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
|
|
||||||
import { isValidStartBlockType } from '@/lib/workflows/triggers/start-block-types'
|
|
||||||
import type { InputFormatField } from '@/lib/workflows/types'
|
|
||||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
|
||||||
import { useSubBlockStore } from '@/stores/workflows/subblock/store'
|
|
||||||
import { useWorkflowStore } from '@/stores/workflows/workflow/store'
|
|
||||||
|
|
||||||
type NormalizedField = InputFormatField & { name: string }
|
|
||||||
|
|
||||||
interface ApiInfoModalProps {
|
|
||||||
open: boolean
|
|
||||||
onOpenChange: (open: boolean) => void
|
|
||||||
workflowId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ApiInfoModal({ open, onOpenChange, workflowId }: ApiInfoModalProps) {
|
|
||||||
const blocks = useWorkflowStore((state) => state.blocks)
|
|
||||||
const setValue = useSubBlockStore((state) => state.setValue)
|
|
||||||
const subBlockValues = useSubBlockStore((state) =>
|
|
||||||
workflowId ? (state.workflowValues[workflowId] ?? {}) : {}
|
|
||||||
)
|
|
||||||
|
|
||||||
const workflowMetadata = useWorkflowRegistry((state) =>
|
|
||||||
workflowId ? state.workflows[workflowId] : undefined
|
|
||||||
)
|
|
||||||
const updateWorkflow = useWorkflowRegistry((state) => state.updateWorkflow)
|
|
||||||
|
|
||||||
const [description, setDescription] = useState('')
|
|
||||||
const [paramDescriptions, setParamDescriptions] = useState<Record<string, string>>({})
|
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
|
||||||
const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false)
|
|
||||||
|
|
||||||
const initialDescriptionRef = useRef('')
|
|
||||||
const initialParamDescriptionsRef = useRef<Record<string, string>>({})
|
|
||||||
|
|
||||||
const starterBlockId = useMemo(() => {
|
|
||||||
for (const [blockId, block] of Object.entries(blocks)) {
|
|
||||||
if (!block || typeof block !== 'object') continue
|
|
||||||
const blockType = (block as { type?: string }).type
|
|
||||||
if (blockType && isValidStartBlockType(blockType)) {
|
|
||||||
return blockId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}, [blocks])
|
|
||||||
|
|
||||||
const inputFormat = useMemo((): NormalizedField[] => {
|
|
||||||
if (!starterBlockId) return []
|
|
||||||
|
|
||||||
const storeValue = subBlockValues[starterBlockId]?.inputFormat
|
|
||||||
const normalized = normalizeInputFormatValue(storeValue) as NormalizedField[]
|
|
||||||
if (normalized.length > 0) return normalized
|
|
||||||
|
|
||||||
const startBlock = blocks[starterBlockId]
|
|
||||||
const blockValue = startBlock?.subBlocks?.inputFormat?.value
|
|
||||||
return normalizeInputFormatValue(blockValue) as NormalizedField[]
|
|
||||||
}, [starterBlockId, subBlockValues, blocks])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
const normalizedDesc = workflowMetadata?.description?.toLowerCase().trim()
|
|
||||||
const isDefaultDescription =
|
|
||||||
!workflowMetadata?.description ||
|
|
||||||
workflowMetadata.description === workflowMetadata.name ||
|
|
||||||
normalizedDesc === 'new workflow' ||
|
|
||||||
normalizedDesc === 'your first workflow - start building here!'
|
|
||||||
|
|
||||||
const initialDescription = isDefaultDescription ? '' : workflowMetadata?.description || ''
|
|
||||||
setDescription(initialDescription)
|
|
||||||
initialDescriptionRef.current = initialDescription
|
|
||||||
|
|
||||||
const descriptions: Record<string, string> = {}
|
|
||||||
for (const field of inputFormat) {
|
|
||||||
if (field.description) {
|
|
||||||
descriptions[field.name] = field.description
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setParamDescriptions(descriptions)
|
|
||||||
initialParamDescriptionsRef.current = { ...descriptions }
|
|
||||||
}
|
|
||||||
}, [open, workflowMetadata, inputFormat])
|
|
||||||
|
|
||||||
const hasChanges = useMemo(() => {
|
|
||||||
if (description.trim() !== initialDescriptionRef.current.trim()) return true
|
|
||||||
|
|
||||||
for (const field of inputFormat) {
|
|
||||||
const currentValue = (paramDescriptions[field.name] || '').trim()
|
|
||||||
const initialValue = (initialParamDescriptionsRef.current[field.name] || '').trim()
|
|
||||||
if (currentValue !== initialValue) return true
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}, [description, paramDescriptions, inputFormat])
|
|
||||||
|
|
||||||
const handleParamDescriptionChange = (fieldName: string, value: string) => {
|
|
||||||
setParamDescriptions((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[fieldName]: value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCloseAttempt = useCallback(() => {
|
|
||||||
if (hasChanges && !isSaving) {
|
|
||||||
setShowUnsavedChangesAlert(true)
|
|
||||||
} else {
|
|
||||||
onOpenChange(false)
|
|
||||||
}
|
|
||||||
}, [hasChanges, isSaving, onOpenChange])
|
|
||||||
|
|
||||||
const handleDiscardChanges = useCallback(() => {
|
|
||||||
setShowUnsavedChangesAlert(false)
|
|
||||||
setDescription(initialDescriptionRef.current)
|
|
||||||
setParamDescriptions({ ...initialParamDescriptionsRef.current })
|
|
||||||
onOpenChange(false)
|
|
||||||
}, [onOpenChange])
|
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
|
||||||
if (!workflowId) return
|
|
||||||
|
|
||||||
const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId
|
|
||||||
if (activeWorkflowId !== workflowId) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSaving(true)
|
|
||||||
try {
|
|
||||||
if (description.trim() !== (workflowMetadata?.description || '')) {
|
|
||||||
updateWorkflow(workflowId, { description: description.trim() || 'New workflow' })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (starterBlockId) {
|
|
||||||
const updatedValue = inputFormat.map((field) => ({
|
|
||||||
...field,
|
|
||||||
description: paramDescriptions[field.name]?.trim() || undefined,
|
|
||||||
}))
|
|
||||||
setValue(starterBlockId, 'inputFormat', updatedValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
onOpenChange(false)
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false)
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
workflowId,
|
|
||||||
description,
|
|
||||||
workflowMetadata,
|
|
||||||
updateWorkflow,
|
|
||||||
starterBlockId,
|
|
||||||
inputFormat,
|
|
||||||
paramDescriptions,
|
|
||||||
setValue,
|
|
||||||
onOpenChange,
|
|
||||||
])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Modal open={open} onOpenChange={(openState) => !openState && handleCloseAttempt()}>
|
|
||||||
<ModalContent className='max-w-[480px]'>
|
|
||||||
<ModalHeader>
|
|
||||||
<span>Edit API Info</span>
|
|
||||||
</ModalHeader>
|
|
||||||
<ModalBody className='space-y-[12px]'>
|
|
||||||
<div>
|
|
||||||
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
|
|
||||||
Description
|
|
||||||
</Label>
|
|
||||||
<Textarea
|
|
||||||
placeholder='Describe what this workflow API does...'
|
|
||||||
className='min-h-[80px] resize-none'
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{inputFormat.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
|
|
||||||
Parameters ({inputFormat.length})
|
|
||||||
</Label>
|
|
||||||
<div className='flex flex-col gap-[8px]'>
|
|
||||||
{inputFormat.map((field) => (
|
|
||||||
<div
|
|
||||||
key={field.name}
|
|
||||||
className='overflow-hidden rounded-[4px] border border-[var(--border-1)]'
|
|
||||||
>
|
|
||||||
<div className='flex items-center justify-between bg-[var(--surface-4)] px-[10px] py-[5px]'>
|
|
||||||
<div className='flex min-w-0 flex-1 items-center gap-[8px]'>
|
|
||||||
<span className='block truncate font-medium text-[14px] text-[var(--text-tertiary)]'>
|
|
||||||
{field.name}
|
|
||||||
</span>
|
|
||||||
<Badge size='sm'>{field.type || 'string'}</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className='border-[var(--border-1)] border-t px-[10px] pt-[6px] pb-[10px]'>
|
|
||||||
<div className='flex flex-col gap-[6px]'>
|
|
||||||
<Label className='text-[13px]'>Description</Label>
|
|
||||||
<Input
|
|
||||||
value={paramDescriptions[field.name] || ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleParamDescriptionChange(field.name, e.target.value)
|
|
||||||
}
|
|
||||||
placeholder={`Enter description for ${field.name}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</ModalBody>
|
|
||||||
<ModalFooter>
|
|
||||||
<Button variant='default' onClick={handleCloseAttempt} disabled={isSaving}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button variant='tertiary' onClick={handleSave} disabled={isSaving || !hasChanges}>
|
|
||||||
{isSaving ? 'Saving...' : 'Save'}
|
|
||||||
</Button>
|
|
||||||
</ModalFooter>
|
|
||||||
</ModalContent>
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<Modal open={showUnsavedChangesAlert} onOpenChange={setShowUnsavedChangesAlert}>
|
|
||||||
<ModalContent className='max-w-[400px]'>
|
|
||||||
<ModalHeader>
|
|
||||||
<span>Unsaved Changes</span>
|
|
||||||
</ModalHeader>
|
|
||||||
<ModalBody>
|
|
||||||
<p className='text-[14px] text-[var(--text-secondary)]'>
|
|
||||||
You have unsaved changes. Are you sure you want to discard them?
|
|
||||||
</p>
|
|
||||||
</ModalBody>
|
|
||||||
<ModalFooter>
|
|
||||||
<Button variant='default' onClick={() => setShowUnsavedChangesAlert(false)}>
|
|
||||||
Keep Editing
|
|
||||||
</Button>
|
|
||||||
<Button variant='destructive' onClick={handleDiscardChanges}>
|
|
||||||
Discard Changes
|
|
||||||
</Button>
|
|
||||||
</ModalFooter>
|
|
||||||
</ModalContent>
|
|
||||||
</Modal>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -283,7 +283,7 @@ export function GeneralDeploy({
|
|||||||
<ModalContent size='sm'>
|
<ModalContent size='sm'>
|
||||||
<ModalHeader>Promote to live</ModalHeader>
|
<ModalHeader>Promote to live</ModalHeader>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||||
Are you sure you want to promote{' '}
|
Are you sure you want to promote{' '}
|
||||||
<span className='font-medium text-[var(--text-primary)]'>
|
<span className='font-medium text-[var(--text-primary)]'>
|
||||||
{versionToPromoteInfo?.name || `v${versionToPromote}`}
|
{versionToPromoteInfo?.name || `v${versionToPromote}`}
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
|||||||
import { A2aDeploy } from './components/a2a/a2a'
|
import { A2aDeploy } from './components/a2a/a2a'
|
||||||
import { ApiDeploy } from './components/api/api'
|
import { ApiDeploy } from './components/api/api'
|
||||||
import { ChatDeploy, type ExistingChat } from './components/chat/chat'
|
import { ChatDeploy, type ExistingChat } from './components/chat/chat'
|
||||||
import { ApiInfoModal } from './components/general/components/api-info-modal'
|
|
||||||
import { GeneralDeploy } from './components/general/general'
|
import { GeneralDeploy } from './components/general/general'
|
||||||
import { McpDeploy } from './components/mcp/mcp'
|
import { McpDeploy } from './components/mcp/mcp'
|
||||||
import { TemplateDeploy } from './components/template/template'
|
import { TemplateDeploy } from './components/template/template'
|
||||||
@@ -111,7 +110,6 @@ export function DeployModal({
|
|||||||
const [chatSuccess, setChatSuccess] = useState(false)
|
const [chatSuccess, setChatSuccess] = useState(false)
|
||||||
|
|
||||||
const [isCreateKeyModalOpen, setIsCreateKeyModalOpen] = useState(false)
|
const [isCreateKeyModalOpen, setIsCreateKeyModalOpen] = useState(false)
|
||||||
const [isApiInfoModalOpen, setIsApiInfoModalOpen] = useState(false)
|
|
||||||
const userPermissions = useUserPermissionsContext()
|
const userPermissions = useUserPermissionsContext()
|
||||||
const canManageWorkspaceKeys = userPermissions.canAdmin
|
const canManageWorkspaceKeys = userPermissions.canAdmin
|
||||||
const { config: permissionConfig } = usePermissionConfig()
|
const { config: permissionConfig } = usePermissionConfig()
|
||||||
@@ -391,6 +389,11 @@ export function DeployModal({
|
|||||||
form?.requestSubmit()
|
form?.requestSubmit()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const handleA2aFormSubmit = useCallback(() => {
|
||||||
|
const form = document.getElementById('a2a-deploy-form') as HTMLFormElement
|
||||||
|
form?.requestSubmit()
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleA2aPublish = useCallback(() => {
|
const handleA2aPublish = useCallback(() => {
|
||||||
const form = document.getElementById('a2a-deploy-form')
|
const form = document.getElementById('a2a-deploy-form')
|
||||||
const publishTrigger = form?.querySelector('[data-a2a-publish-trigger]') as HTMLButtonElement
|
const publishTrigger = form?.querySelector('[data-a2a-publish-trigger]') as HTMLButtonElement
|
||||||
@@ -593,9 +596,6 @@ export function DeployModal({
|
|||||||
<ModalFooter className='items-center justify-between'>
|
<ModalFooter className='items-center justify-between'>
|
||||||
<div />
|
<div />
|
||||||
<div className='flex items-center gap-2'>
|
<div className='flex items-center gap-2'>
|
||||||
<Button variant='default' onClick={() => setIsApiInfoModalOpen(true)}>
|
|
||||||
Edit API Info
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
variant='tertiary'
|
variant='tertiary'
|
||||||
onClick={() => setIsCreateKeyModalOpen(true)}
|
onClick={() => setIsCreateKeyModalOpen(true)}
|
||||||
@@ -880,14 +880,6 @@ export function DeployModal({
|
|||||||
canManageWorkspaceKeys={canManageWorkspaceKeys}
|
canManageWorkspaceKeys={canManageWorkspaceKeys}
|
||||||
defaultKeyType={defaultKeyType}
|
defaultKeyType={defaultKeyType}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{workflowId && (
|
|
||||||
<ApiInfoModal
|
|
||||||
open={isApiInfoModalOpen}
|
|
||||||
onOpenChange={setIsApiInfoModalOpen}
|
|
||||||
workflowId={workflowId}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ export function CodeEditor({
|
|||||||
placeholder = '',
|
placeholder = '',
|
||||||
className = '',
|
className = '',
|
||||||
gutterClassName = '',
|
gutterClassName = '',
|
||||||
minHeight,
|
minHeight = '360px',
|
||||||
highlightVariables = true,
|
highlightVariables = true,
|
||||||
onKeyDown,
|
onKeyDown,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
@@ -186,7 +186,7 @@ export function CodeEditor({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Code.Container className={className} style={minHeight ? { minHeight } : undefined}>
|
<Code.Container className={className} style={{ minHeight }}>
|
||||||
{showWandButton && onWandClick && (
|
{showWandButton && onWandClick && (
|
||||||
<Button
|
<Button
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
@@ -220,7 +220,7 @@ export function CodeEditor({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
{...getCodeEditorProps({ disabled })}
|
{...getCodeEditorProps({ disabled })}
|
||||||
className={cn(getCodeEditorProps({ disabled }).className, 'h-full')}
|
className={cn(getCodeEditorProps({ disabled }).className, 'h-full')}
|
||||||
style={minHeight ? { minHeight } : undefined}
|
style={{ minHeight }}
|
||||||
textareaClassName={cn(
|
textareaClassName={cn(
|
||||||
getCodeEditorProps({ disabled }).textareaClassName,
|
getCodeEditorProps({ disabled }).textareaClassName,
|
||||||
'!block !h-full !min-h-full'
|
'!block !h-full !min-h-full'
|
||||||
|
|||||||
@@ -87,16 +87,15 @@ export function CustomToolModal({
|
|||||||
const [codeError, setCodeError] = useState<string | null>(null)
|
const [codeError, setCodeError] = useState<string | null>(null)
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
const [toolId, setToolId] = useState<string | undefined>(undefined)
|
const [toolId, setToolId] = useState<string | undefined>(undefined)
|
||||||
const [initialJsonSchema, setInitialJsonSchema] = useState('')
|
|
||||||
const [initialFunctionCode, setInitialFunctionCode] = useState('')
|
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||||
const [showDiscardAlert, setShowDiscardAlert] = useState(false)
|
|
||||||
const [isSchemaPromptActive, setIsSchemaPromptActive] = useState(false)
|
const [isSchemaPromptActive, setIsSchemaPromptActive] = useState(false)
|
||||||
const [schemaPromptInput, setSchemaPromptInput] = useState('')
|
const [schemaPromptInput, setSchemaPromptInput] = useState('')
|
||||||
|
const [schemaPromptSummary, setSchemaPromptSummary] = useState<string | null>(null)
|
||||||
const schemaPromptInputRef = useRef<HTMLInputElement | null>(null)
|
const schemaPromptInputRef = useRef<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
const [isCodePromptActive, setIsCodePromptActive] = useState(false)
|
const [isCodePromptActive, setIsCodePromptActive] = useState(false)
|
||||||
const [codePromptInput, setCodePromptInput] = useState('')
|
const [codePromptInput, setCodePromptInput] = useState('')
|
||||||
|
const [codePromptSummary, setCodePromptSummary] = useState<string | null>(null)
|
||||||
const codePromptInputRef = useRef<HTMLInputElement | null>(null)
|
const codePromptInputRef = useRef<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
const schemaGeneration = useWand({
|
const schemaGeneration = useWand({
|
||||||
@@ -175,9 +174,6 @@ Example 2:
|
|||||||
generationType: 'custom-tool-schema',
|
generationType: 'custom-tool-schema',
|
||||||
},
|
},
|
||||||
currentValue: jsonSchema,
|
currentValue: jsonSchema,
|
||||||
onStreamStart: () => {
|
|
||||||
setJsonSchema('')
|
|
||||||
},
|
|
||||||
onGeneratedContent: (content) => {
|
onGeneratedContent: (content) => {
|
||||||
setJsonSchema(content)
|
setJsonSchema(content)
|
||||||
setSchemaError(null)
|
setSchemaError(null)
|
||||||
@@ -241,9 +237,6 @@ try {
|
|||||||
generationType: 'javascript-function-body',
|
generationType: 'javascript-function-body',
|
||||||
},
|
},
|
||||||
currentValue: functionCode,
|
currentValue: functionCode,
|
||||||
onStreamStart: () => {
|
|
||||||
setFunctionCode('')
|
|
||||||
},
|
|
||||||
onGeneratedContent: (content) => {
|
onGeneratedContent: (content) => {
|
||||||
handleFunctionCodeChange(content)
|
handleFunctionCodeChange(content)
|
||||||
setCodeError(null)
|
setCodeError(null)
|
||||||
@@ -279,15 +272,12 @@ try {
|
|||||||
|
|
||||||
if (initialValues) {
|
if (initialValues) {
|
||||||
try {
|
try {
|
||||||
const schemaValue =
|
setJsonSchema(
|
||||||
typeof initialValues.schema === 'string'
|
typeof initialValues.schema === 'string'
|
||||||
? initialValues.schema
|
? initialValues.schema
|
||||||
: JSON.stringify(initialValues.schema, null, 2)
|
: JSON.stringify(initialValues.schema, null, 2)
|
||||||
const codeValue = initialValues.code || ''
|
)
|
||||||
setJsonSchema(schemaValue)
|
setFunctionCode(initialValues.code || '')
|
||||||
setFunctionCode(codeValue)
|
|
||||||
setInitialJsonSchema(schemaValue)
|
|
||||||
setInitialFunctionCode(codeValue)
|
|
||||||
setIsEditing(true)
|
setIsEditing(true)
|
||||||
setToolId(initialValues.id)
|
setToolId(initialValues.id)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -314,18 +304,17 @@ try {
|
|||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setJsonSchema('')
|
setJsonSchema('')
|
||||||
setFunctionCode('')
|
setFunctionCode('')
|
||||||
setInitialJsonSchema('')
|
|
||||||
setInitialFunctionCode('')
|
|
||||||
setSchemaError(null)
|
setSchemaError(null)
|
||||||
setCodeError(null)
|
setCodeError(null)
|
||||||
setActiveSection('schema')
|
setActiveSection('schema')
|
||||||
setIsEditing(false)
|
setIsEditing(false)
|
||||||
setToolId(undefined)
|
setToolId(undefined)
|
||||||
|
setSchemaPromptSummary(null)
|
||||||
|
setCodePromptSummary(null)
|
||||||
setIsSchemaPromptActive(false)
|
setIsSchemaPromptActive(false)
|
||||||
setIsCodePromptActive(false)
|
setIsCodePromptActive(false)
|
||||||
setSchemaPromptInput('')
|
setSchemaPromptInput('')
|
||||||
setCodePromptInput('')
|
setCodePromptInput('')
|
||||||
setShowDiscardAlert(false)
|
|
||||||
schemaGeneration.closePrompt()
|
schemaGeneration.closePrompt()
|
||||||
schemaGeneration.hidePromptInline()
|
schemaGeneration.hidePromptInline()
|
||||||
codeGeneration.closePrompt()
|
codeGeneration.closePrompt()
|
||||||
@@ -339,37 +328,31 @@ try {
|
|||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const validateSchema = (schema: string): { isValid: boolean; error: string | null } => {
|
const validateJsonSchema = (schema: string): boolean => {
|
||||||
if (!schema) return { isValid: false, error: null }
|
if (!schema) return false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(schema)
|
const parsed = JSON.parse(schema)
|
||||||
|
|
||||||
if (!parsed.type || parsed.type !== 'function') {
|
if (!parsed.type || parsed.type !== 'function') {
|
||||||
return { isValid: false, error: 'Missing "type": "function"' }
|
return false
|
||||||
}
|
|
||||||
if (!parsed.function || !parsed.function.name) {
|
|
||||||
return { isValid: false, error: 'Missing function.name field' }
|
|
||||||
}
|
|
||||||
if (!parsed.function.parameters) {
|
|
||||||
return { isValid: false, error: 'Missing function.parameters object' }
|
|
||||||
}
|
|
||||||
if (!parsed.function.parameters.type) {
|
|
||||||
return { isValid: false, error: 'Missing parameters.type field' }
|
|
||||||
}
|
|
||||||
if (parsed.function.parameters.properties === undefined) {
|
|
||||||
return { isValid: false, error: 'Missing parameters.properties field' }
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
typeof parsed.function.parameters.properties !== 'object' ||
|
|
||||||
parsed.function.parameters.properties === null
|
|
||||||
) {
|
|
||||||
return { isValid: false, error: 'parameters.properties must be an object' }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { isValid: true, error: null }
|
if (!parsed.function || !parsed.function.name) {
|
||||||
} catch {
|
return false
|
||||||
return { isValid: false, error: 'Invalid JSON format' }
|
}
|
||||||
|
|
||||||
|
if (!parsed.function.parameters) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed.function.parameters.type || parsed.function.parameters.properties === undefined) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
} catch (_error) {
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,32 +374,7 @@ try {
|
|||||||
}
|
}
|
||||||
}, [jsonSchema])
|
}, [jsonSchema])
|
||||||
|
|
||||||
const isSchemaValid = useMemo(() => validateSchema(jsonSchema).isValid, [jsonSchema])
|
const isSchemaValid = useMemo(() => validateJsonSchema(jsonSchema), [jsonSchema])
|
||||||
|
|
||||||
const hasChanges = useMemo(() => {
|
|
||||||
if (!isEditing) return true
|
|
||||||
return jsonSchema !== initialJsonSchema || functionCode !== initialFunctionCode
|
|
||||||
}, [isEditing, jsonSchema, initialJsonSchema, functionCode, initialFunctionCode])
|
|
||||||
|
|
||||||
const hasUnsavedChanges = useMemo(() => {
|
|
||||||
if (isEditing) {
|
|
||||||
return jsonSchema !== initialJsonSchema || functionCode !== initialFunctionCode
|
|
||||||
}
|
|
||||||
return jsonSchema.trim().length > 0 || functionCode.trim().length > 0
|
|
||||||
}, [isEditing, jsonSchema, initialJsonSchema, functionCode, initialFunctionCode])
|
|
||||||
|
|
||||||
const handleCloseAttempt = () => {
|
|
||||||
if (hasUnsavedChanges && !schemaGeneration.isStreaming && !codeGeneration.isStreaming) {
|
|
||||||
setShowDiscardAlert(true)
|
|
||||||
} else {
|
|
||||||
handleClose()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleConfirmDiscard = () => {
|
|
||||||
setShowDiscardAlert(false)
|
|
||||||
handleClose()
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -426,9 +384,43 @@ try {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const { isValid, error } = validateSchema(jsonSchema)
|
const parsed = JSON.parse(jsonSchema)
|
||||||
if (!isValid) {
|
|
||||||
setSchemaError(error)
|
if (!parsed.type || parsed.type !== 'function') {
|
||||||
|
setSchemaError('Schema must have a "type" field set to "function"')
|
||||||
|
setActiveSection('schema')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed.function || !parsed.function.name) {
|
||||||
|
setSchemaError('Schema must have a "function" object with a "name" field')
|
||||||
|
setActiveSection('schema')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed.function.parameters) {
|
||||||
|
setSchemaError('Missing function.parameters object')
|
||||||
|
setActiveSection('schema')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed.function.parameters.type) {
|
||||||
|
setSchemaError('Missing parameters.type field')
|
||||||
|
setActiveSection('schema')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.function.parameters.properties === undefined) {
|
||||||
|
setSchemaError('Missing parameters.properties field')
|
||||||
|
setActiveSection('schema')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof parsed.function.parameters.properties !== 'object' ||
|
||||||
|
parsed.function.parameters.properties === null
|
||||||
|
) {
|
||||||
|
setSchemaError('parameters.properties must be an object')
|
||||||
setActiveSection('schema')
|
setActiveSection('schema')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -491,9 +483,17 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onSave(customTool)
|
onSave(customTool)
|
||||||
|
|
||||||
|
setSchemaPromptSummary(null)
|
||||||
|
setCodePromptSummary(null)
|
||||||
|
|
||||||
handleClose()
|
handleClose()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error saving custom tool:', { error })
|
logger.error('Error saving custom tool:', { error })
|
||||||
|
|
||||||
|
setSchemaPromptSummary(null)
|
||||||
|
setCodePromptSummary(null)
|
||||||
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Failed to save custom tool'
|
const errorMessage = error instanceof Error ? error.message : 'Failed to save custom tool'
|
||||||
|
|
||||||
if (errorMessage.includes('Cannot change function name')) {
|
if (errorMessage.includes('Cannot change function name')) {
|
||||||
@@ -512,8 +512,46 @@ try {
|
|||||||
setJsonSchema(value)
|
setJsonSchema(value)
|
||||||
|
|
||||||
if (value.trim()) {
|
if (value.trim()) {
|
||||||
const { error } = validateSchema(value)
|
try {
|
||||||
setSchemaError(error)
|
const parsed = JSON.parse(value)
|
||||||
|
|
||||||
|
if (!parsed.type || parsed.type !== 'function') {
|
||||||
|
setSchemaError('Missing "type": "function"')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed.function || !parsed.function.name) {
|
||||||
|
setSchemaError('Missing function.name field')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed.function.parameters) {
|
||||||
|
setSchemaError('Missing function.parameters object')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parsed.function.parameters.type) {
|
||||||
|
setSchemaError('Missing parameters.type field')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.function.parameters.properties === undefined) {
|
||||||
|
setSchemaError('Missing parameters.properties field')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof parsed.function.parameters.properties !== 'object' ||
|
||||||
|
parsed.function.parameters.properties === null
|
||||||
|
) {
|
||||||
|
setSchemaError('parameters.properties must be an object')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSchemaError(null)
|
||||||
|
} catch {
|
||||||
|
setSchemaError('Invalid JSON format')
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setSchemaError(null)
|
setSchemaError(null)
|
||||||
}
|
}
|
||||||
@@ -671,12 +709,12 @@ try {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
setSchemaParamSelectedIndex((prev) => Math.min(prev + 1, schemaParameters.length - 1))
|
setSchemaParamSelectedIndex((prev) => Math.min(prev + 1, schemaParameters.length - 1))
|
||||||
return
|
break
|
||||||
case 'ArrowUp':
|
case 'ArrowUp':
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
setSchemaParamSelectedIndex((prev) => Math.max(prev - 1, 0))
|
setSchemaParamSelectedIndex((prev) => Math.max(prev - 1, 0))
|
||||||
return
|
break
|
||||||
case 'Enter':
|
case 'Enter':
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
@@ -684,17 +722,14 @@ try {
|
|||||||
const selectedParam = schemaParameters[schemaParamSelectedIndex]
|
const selectedParam = schemaParameters[schemaParamSelectedIndex]
|
||||||
handleSchemaParamSelect(selectedParam.name)
|
handleSchemaParamSelect(selectedParam.name)
|
||||||
}
|
}
|
||||||
return
|
break
|
||||||
case 'Escape':
|
case 'Escape':
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
setShowSchemaParams(false)
|
setShowSchemaParams(false)
|
||||||
return
|
break
|
||||||
case ' ':
|
|
||||||
case 'Tab':
|
|
||||||
setShowSchemaParams(false)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showEnvVars || showTags) {
|
if (showEnvVars || showTags) {
|
||||||
@@ -708,7 +743,7 @@ try {
|
|||||||
const handleSchemaWandClick = () => {
|
const handleSchemaWandClick = () => {
|
||||||
if (schemaGeneration.isLoading || schemaGeneration.isStreaming) return
|
if (schemaGeneration.isLoading || schemaGeneration.isStreaming) return
|
||||||
setIsSchemaPromptActive(true)
|
setIsSchemaPromptActive(true)
|
||||||
setSchemaPromptInput('')
|
setSchemaPromptInput(schemaPromptSummary ?? '')
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
schemaPromptInputRef.current?.focus()
|
schemaPromptInputRef.current?.focus()
|
||||||
}, 0)
|
}, 0)
|
||||||
@@ -727,6 +762,7 @@ try {
|
|||||||
const handleSchemaPromptSubmit = () => {
|
const handleSchemaPromptSubmit = () => {
|
||||||
const trimmedPrompt = schemaPromptInput.trim()
|
const trimmedPrompt = schemaPromptInput.trim()
|
||||||
if (!trimmedPrompt || schemaGeneration.isLoading || schemaGeneration.isStreaming) return
|
if (!trimmedPrompt || schemaGeneration.isLoading || schemaGeneration.isStreaming) return
|
||||||
|
setSchemaPromptSummary(trimmedPrompt)
|
||||||
schemaGeneration.generateStream({ prompt: trimmedPrompt })
|
schemaGeneration.generateStream({ prompt: trimmedPrompt })
|
||||||
setSchemaPromptInput('')
|
setSchemaPromptInput('')
|
||||||
setIsSchemaPromptActive(false)
|
setIsSchemaPromptActive(false)
|
||||||
@@ -746,7 +782,7 @@ try {
|
|||||||
const handleCodeWandClick = () => {
|
const handleCodeWandClick = () => {
|
||||||
if (codeGeneration.isLoading || codeGeneration.isStreaming) return
|
if (codeGeneration.isLoading || codeGeneration.isStreaming) return
|
||||||
setIsCodePromptActive(true)
|
setIsCodePromptActive(true)
|
||||||
setCodePromptInput('')
|
setCodePromptInput(codePromptSummary ?? '')
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
codePromptInputRef.current?.focus()
|
codePromptInputRef.current?.focus()
|
||||||
}, 0)
|
}, 0)
|
||||||
@@ -765,6 +801,7 @@ try {
|
|||||||
const handleCodePromptSubmit = () => {
|
const handleCodePromptSubmit = () => {
|
||||||
const trimmedPrompt = codePromptInput.trim()
|
const trimmedPrompt = codePromptInput.trim()
|
||||||
if (!trimmedPrompt || codeGeneration.isLoading || codeGeneration.isStreaming) return
|
if (!trimmedPrompt || codeGeneration.isLoading || codeGeneration.isStreaming) return
|
||||||
|
setCodePromptSummary(trimmedPrompt)
|
||||||
codeGeneration.generateStream({ prompt: trimmedPrompt })
|
codeGeneration.generateStream({ prompt: trimmedPrompt })
|
||||||
setCodePromptInput('')
|
setCodePromptInput('')
|
||||||
setIsCodePromptActive(false)
|
setIsCodePromptActive(false)
|
||||||
@@ -809,8 +846,19 @@ try {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Modal open={open} onOpenChange={handleCloseAttempt}>
|
<Modal open={open} onOpenChange={handleClose}>
|
||||||
<ModalContent size='xl'>
|
<ModalContent
|
||||||
|
size='xl'
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape' && (showEnvVars || showTags || showSchemaParams)) {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setShowEnvVars(false)
|
||||||
|
setShowTags(false)
|
||||||
|
setShowSchemaParams(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<ModalHeader>{isEditing ? 'Edit Agent Tool' : 'Create Agent Tool'}</ModalHeader>
|
<ModalHeader>{isEditing ? 'Edit Agent Tool' : 'Create Agent Tool'}</ModalHeader>
|
||||||
|
|
||||||
<ModalTabs
|
<ModalTabs
|
||||||
@@ -1163,7 +1211,7 @@ try {
|
|||||||
<Button
|
<Button
|
||||||
variant='tertiary'
|
variant='tertiary'
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={!isSchemaValid || !!schemaError || !hasChanges}
|
disabled={!isSchemaValid || !!schemaError}
|
||||||
>
|
>
|
||||||
{isEditing ? 'Update Tool' : 'Save Tool'}
|
{isEditing ? 'Update Tool' : 'Save Tool'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -1200,26 +1248,6 @@ try {
|
|||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal open={showDiscardAlert} onOpenChange={setShowDiscardAlert}>
|
|
||||||
<ModalContent size='sm'>
|
|
||||||
<ModalHeader>Unsaved Changes</ModalHeader>
|
|
||||||
<ModalBody>
|
|
||||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
|
||||||
You have unsaved changes to this tool. Are you sure you want to discard your changes
|
|
||||||
and close the editor?
|
|
||||||
</p>
|
|
||||||
</ModalBody>
|
|
||||||
<ModalFooter>
|
|
||||||
<Button variant='default' onClick={() => setShowDiscardAlert(false)}>
|
|
||||||
Keep Editing
|
|
||||||
</Button>
|
|
||||||
<Button variant='destructive' onClick={handleConfirmDiscard}>
|
|
||||||
Discard Changes
|
|
||||||
</Button>
|
|
||||||
</ModalFooter>
|
|
||||||
</ModalContent>
|
|
||||||
</Modal>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ const OutputCodeContent = React.memo(function OutputCodeContent({
|
|||||||
code={code}
|
code={code}
|
||||||
showGutter
|
showGutter
|
||||||
language={language}
|
language={language}
|
||||||
className='m-0 min-h-full rounded-none border-0 bg-[var(--surface-1)] dark:bg-[var(--surface-1)]'
|
className='m-0 min-h-full rounded-none border-0 bg-[var(--surface-1)]'
|
||||||
paddingLeft={8}
|
paddingLeft={8}
|
||||||
gutterStyle={{ backgroundColor: 'transparent' }}
|
gutterStyle={{ backgroundColor: 'transparent' }}
|
||||||
wrapText={wrapText}
|
wrapText={wrapText}
|
||||||
@@ -624,7 +624,7 @@ const OutputPanel = React.memo(function OutputPanel({
|
|||||||
</Tooltip.Content>
|
</Tooltip.Content>
|
||||||
</Tooltip.Root>
|
</Tooltip.Root>
|
||||||
)}
|
)}
|
||||||
<Popover open={outputOptionsOpen} onOpenChange={setOutputOptionsOpen} size='sm'>
|
<Popover open={outputOptionsOpen} onOpenChange={setOutputOptionsOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
@@ -648,7 +648,7 @@ const OutputPanel = React.memo(function OutputPanel({
|
|||||||
>
|
>
|
||||||
<PopoverItem
|
<PopoverItem
|
||||||
active={wrapText}
|
active={wrapText}
|
||||||
showCheck={wrapText}
|
showCheck
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
setWrapText(!wrapText)
|
setWrapText(!wrapText)
|
||||||
@@ -658,7 +658,7 @@ const OutputPanel = React.memo(function OutputPanel({
|
|||||||
</PopoverItem>
|
</PopoverItem>
|
||||||
<PopoverItem
|
<PopoverItem
|
||||||
active={openOnRun}
|
active={openOnRun}
|
||||||
showCheck={openOnRun}
|
showCheck
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
setOpenOnRun(!openOnRun)
|
setOpenOnRun(!openOnRun)
|
||||||
@@ -1472,7 +1472,7 @@ export const Terminal = memo(function Terminal() {
|
|||||||
>
|
>
|
||||||
{uniqueBlocks.length > 0 ? (
|
{uniqueBlocks.length > 0 ? (
|
||||||
<div className={clsx(COLUMN_WIDTHS.BLOCK, COLUMN_BASE_CLASS, 'flex items-center')}>
|
<div className={clsx(COLUMN_WIDTHS.BLOCK, COLUMN_BASE_CLASS, 'flex items-center')}>
|
||||||
<Popover open={blockFilterOpen} onOpenChange={setBlockFilterOpen} size='sm'>
|
<Popover open={blockFilterOpen} onOpenChange={setBlockFilterOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
@@ -1508,12 +1508,12 @@ export const Terminal = memo(function Terminal() {
|
|||||||
<PopoverItem
|
<PopoverItem
|
||||||
key={block.blockId}
|
key={block.blockId}
|
||||||
active={isSelected}
|
active={isSelected}
|
||||||
showCheck={isSelected}
|
|
||||||
onClick={() => toggleBlock(block.blockId)}
|
onClick={() => toggleBlock(block.blockId)}
|
||||||
className={index > 0 ? 'mt-[2px]' : ''}
|
className={index > 0 ? 'mt-[2px]' : ''}
|
||||||
>
|
>
|
||||||
{BlockIcon && <BlockIcon className='h-3 w-3' />}
|
{BlockIcon && <BlockIcon className='h-3 w-3' />}
|
||||||
<span className='flex-1'>{block.blockName}</span>
|
<span className='flex-1'>{block.blockName}</span>
|
||||||
|
{isSelected && <Check className='h-3 w-3' />}
|
||||||
</PopoverItem>
|
</PopoverItem>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -1526,7 +1526,7 @@ export const Terminal = memo(function Terminal() {
|
|||||||
)}
|
)}
|
||||||
{hasStatusEntries ? (
|
{hasStatusEntries ? (
|
||||||
<div className={clsx(COLUMN_WIDTHS.STATUS, COLUMN_BASE_CLASS, 'flex items-center')}>
|
<div className={clsx(COLUMN_WIDTHS.STATUS, COLUMN_BASE_CLASS, 'flex items-center')}>
|
||||||
<Popover open={statusFilterOpen} onOpenChange={setStatusFilterOpen} size='sm'>
|
<Popover open={statusFilterOpen} onOpenChange={setStatusFilterOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
@@ -1555,7 +1555,6 @@ export const Terminal = memo(function Terminal() {
|
|||||||
<PopoverScrollArea style={{ maxHeight: '140px' }}>
|
<PopoverScrollArea style={{ maxHeight: '140px' }}>
|
||||||
<PopoverItem
|
<PopoverItem
|
||||||
active={filters.statuses.has('error')}
|
active={filters.statuses.has('error')}
|
||||||
showCheck={filters.statuses.has('error')}
|
|
||||||
onClick={() => toggleStatus('error')}
|
onClick={() => toggleStatus('error')}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -1563,10 +1562,10 @@ export const Terminal = memo(function Terminal() {
|
|||||||
style={{ backgroundColor: 'var(--text-error)' }}
|
style={{ backgroundColor: 'var(--text-error)' }}
|
||||||
/>
|
/>
|
||||||
<span className='flex-1'>Error</span>
|
<span className='flex-1'>Error</span>
|
||||||
|
{filters.statuses.has('error') && <Check className='h-3 w-3' />}
|
||||||
</PopoverItem>
|
</PopoverItem>
|
||||||
<PopoverItem
|
<PopoverItem
|
||||||
active={filters.statuses.has('info')}
|
active={filters.statuses.has('info')}
|
||||||
showCheck={filters.statuses.has('info')}
|
|
||||||
onClick={() => toggleStatus('info')}
|
onClick={() => toggleStatus('info')}
|
||||||
className='mt-[2px]'
|
className='mt-[2px]'
|
||||||
>
|
>
|
||||||
@@ -1575,6 +1574,7 @@ export const Terminal = memo(function Terminal() {
|
|||||||
style={{ backgroundColor: 'var(--terminal-status-info-color)' }}
|
style={{ backgroundColor: 'var(--terminal-status-info-color)' }}
|
||||||
/>
|
/>
|
||||||
<span className='flex-1'>Info</span>
|
<span className='flex-1'>Info</span>
|
||||||
|
{filters.statuses.has('info') && <Check className='h-3 w-3' />}
|
||||||
</PopoverItem>
|
</PopoverItem>
|
||||||
</PopoverScrollArea>
|
</PopoverScrollArea>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
@@ -1585,7 +1585,7 @@ export const Terminal = memo(function Terminal() {
|
|||||||
)}
|
)}
|
||||||
{uniqueRunIds.length > 0 ? (
|
{uniqueRunIds.length > 0 ? (
|
||||||
<div className={clsx(COLUMN_WIDTHS.RUN_ID, COLUMN_BASE_CLASS, 'flex items-center')}>
|
<div className={clsx(COLUMN_WIDTHS.RUN_ID, COLUMN_BASE_CLASS, 'flex items-center')}>
|
||||||
<Popover open={runIdFilterOpen} onOpenChange={setRunIdFilterOpen} size='sm'>
|
<Popover open={runIdFilterOpen} onOpenChange={setRunIdFilterOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
@@ -1620,16 +1620,16 @@ export const Terminal = memo(function Terminal() {
|
|||||||
<PopoverItem
|
<PopoverItem
|
||||||
key={runId}
|
key={runId}
|
||||||
active={isSelected}
|
active={isSelected}
|
||||||
showCheck={isSelected}
|
|
||||||
onClick={() => toggleRunId(runId)}
|
onClick={() => toggleRunId(runId)}
|
||||||
className={index > 0 ? 'mt-[2px]' : ''}
|
className={index > 0 ? 'mt-[2px]' : ''}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className='flex-1 font-mono text-[11px]'
|
className='flex-1 font-mono text-[12px]'
|
||||||
style={{ color: runIdColor || '#D2D2D2' }}
|
style={{ color: runIdColor || '#D2D2D2' }}
|
||||||
>
|
>
|
||||||
{formatRunId(runId)}
|
{formatRunId(runId)}
|
||||||
</span>
|
</span>
|
||||||
|
{isSelected && <Check className='h-3 w-3' />}
|
||||||
</PopoverItem>
|
</PopoverItem>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -1765,7 +1765,7 @@ export const Terminal = memo(function Terminal() {
|
|||||||
</Tooltip.Root>
|
</Tooltip.Root>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Popover open={mainOptionsOpen} onOpenChange={setMainOptionsOpen} size='sm'>
|
<Popover open={mainOptionsOpen} onOpenChange={setMainOptionsOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
@@ -1789,7 +1789,7 @@ export const Terminal = memo(function Terminal() {
|
|||||||
>
|
>
|
||||||
<PopoverItem
|
<PopoverItem
|
||||||
active={openOnRun}
|
active={openOnRun}
|
||||||
showCheck={openOnRun}
|
showCheck
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
setOpenOnRun(!openOnRun)
|
setOpenOnRun(!openOnRun)
|
||||||
|
|||||||
@@ -31,11 +31,9 @@ import {
|
|||||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils'
|
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils'
|
||||||
import { useBlockVisual } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
import { useBlockVisual } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||||
import { useBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions'
|
import { useBlockDimensions } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-block-dimensions'
|
||||||
import { getBlock } from '@/blocks'
|
|
||||||
import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks/types'
|
import { SELECTOR_TYPES_HYDRATION_REQUIRED, type SubBlockConfig } from '@/blocks/types'
|
||||||
import { getDependsOnFields } from '@/blocks/utils'
|
import { getDependsOnFields } from '@/blocks/utils'
|
||||||
import { useKnowledgeBase } from '@/hooks/kb/use-knowledge'
|
import { useKnowledgeBase } from '@/hooks/kb/use-knowledge'
|
||||||
import { useCustomTools } from '@/hooks/queries/custom-tools'
|
|
||||||
import { useMcpServers, useMcpToolsQuery } from '@/hooks/queries/mcp'
|
import { useMcpServers, useMcpToolsQuery } from '@/hooks/queries/mcp'
|
||||||
import { useCredentialName } from '@/hooks/queries/oauth-credentials'
|
import { useCredentialName } from '@/hooks/queries/oauth-credentials'
|
||||||
import { useReactivateSchedule, useScheduleInfo } from '@/hooks/queries/schedules'
|
import { useReactivateSchedule, useScheduleInfo } from '@/hooks/queries/schedules'
|
||||||
@@ -563,59 +561,6 @@ const SubBlockRow = memo(function SubBlockRow({
|
|||||||
return `${names[0]}, ${names[1]} +${names.length - 2}`
|
return `${names[0]}, ${names[1]} +${names.length - 2}`
|
||||||
}, [subBlock?.type, rawValue, workflowVariables])
|
}, [subBlock?.type, rawValue, workflowVariables])
|
||||||
|
|
||||||
/**
|
|
||||||
* Hydrates tool references to display names.
|
|
||||||
* Follows the same pattern as other selectors (Slack channels, MCP tools, etc.)
|
|
||||||
*/
|
|
||||||
const { data: customTools = [] } = useCustomTools(workspaceId || '')
|
|
||||||
|
|
||||||
const toolsDisplayValue = useMemo(() => {
|
|
||||||
if (subBlock?.type !== 'tool-input' || !Array.isArray(rawValue) || rawValue.length === 0) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const toolNames = rawValue
|
|
||||||
.map((tool: any) => {
|
|
||||||
if (!tool || typeof tool !== 'object') return null
|
|
||||||
|
|
||||||
// Priority 1: Use tool.title if already populated
|
|
||||||
if (tool.title && typeof tool.title === 'string') return tool.title
|
|
||||||
|
|
||||||
// Priority 2: Resolve custom tools with reference ID from database
|
|
||||||
if (tool.type === 'custom-tool' && tool.customToolId) {
|
|
||||||
const customTool = customTools.find((t) => t.id === tool.customToolId)
|
|
||||||
if (customTool?.title) return customTool.title
|
|
||||||
if (customTool?.schema?.function?.name) return customTool.schema.function.name
|
|
||||||
}
|
|
||||||
|
|
||||||
// Priority 3: Extract from inline schema (legacy format)
|
|
||||||
if (tool.schema?.function?.name) return tool.schema.function.name
|
|
||||||
|
|
||||||
// Priority 4: Extract from OpenAI function format
|
|
||||||
if (tool.function?.name) return tool.function.name
|
|
||||||
|
|
||||||
// Priority 5: Resolve built-in tool blocks from registry
|
|
||||||
if (
|
|
||||||
typeof tool.type === 'string' &&
|
|
||||||
tool.type !== 'custom-tool' &&
|
|
||||||
tool.type !== 'mcp' &&
|
|
||||||
tool.type !== 'workflow' &&
|
|
||||||
tool.type !== 'workflow_input'
|
|
||||||
) {
|
|
||||||
const blockConfig = getBlock(tool.type)
|
|
||||||
if (blockConfig?.name) return blockConfig.name
|
|
||||||
}
|
|
||||||
|
|
||||||
return null
|
|
||||||
})
|
|
||||||
.filter((name): name is string => !!name)
|
|
||||||
|
|
||||||
if (toolNames.length === 0) return null
|
|
||||||
if (toolNames.length === 1) return toolNames[0]
|
|
||||||
if (toolNames.length === 2) return `${toolNames[0]}, ${toolNames[1]}`
|
|
||||||
return `${toolNames[0]}, ${toolNames[1]} +${toolNames.length - 2}`
|
|
||||||
}, [subBlock?.type, rawValue, customTools, workspaceId])
|
|
||||||
|
|
||||||
const isPasswordField = subBlock?.password === true
|
const isPasswordField = subBlock?.password === true
|
||||||
const maskedValue = isPasswordField && value && value !== '-' ? '•••' : null
|
const maskedValue = isPasswordField && value && value !== '-' ? '•••' : null
|
||||||
|
|
||||||
@@ -624,7 +569,6 @@ const SubBlockRow = memo(function SubBlockRow({
|
|||||||
credentialName ||
|
credentialName ||
|
||||||
dropdownLabel ||
|
dropdownLabel ||
|
||||||
variablesDisplayValue ||
|
variablesDisplayValue ||
|
||||||
toolsDisplayValue ||
|
|
||||||
knowledgeBaseDisplayName ||
|
knowledgeBaseDisplayName ||
|
||||||
workflowSelectionName ||
|
workflowSelectionName ||
|
||||||
mcpServerDisplayName ||
|
mcpServerDisplayName ||
|
||||||
|
|||||||
@@ -3,20 +3,19 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Options for configuring scroll behavior
|
* Options for configuring scroll behavior.
|
||||||
*/
|
*/
|
||||||
interface UseScrollManagementOptions {
|
interface UseScrollManagementOptions {
|
||||||
/**
|
/**
|
||||||
* Scroll behavior for programmatic scrolls
|
* Scroll behavior for programmatic scrolls.
|
||||||
* @remarks
|
* - `smooth`: animated scroll (default, used by Copilot).
|
||||||
* - `smooth`: Animated scroll (default, used by Copilot)
|
* - `auto`: immediate scroll to bottom (used by floating chat to avoid jitter).
|
||||||
* - `auto`: Immediate scroll to bottom (used by floating chat to avoid jitter)
|
|
||||||
*/
|
*/
|
||||||
behavior?: 'auto' | 'smooth'
|
behavior?: 'auto' | 'smooth'
|
||||||
/**
|
/**
|
||||||
* Distance from bottom (in pixels) within which auto-scroll stays active
|
* Distance from bottom (in pixels) within which auto-scroll stays active.
|
||||||
* @remarks Lower values = less sticky (user can scroll away easier)
|
* Lower values = less sticky (user can scroll away easier).
|
||||||
* @defaultValue 100
|
* Default is 100px.
|
||||||
*/
|
*/
|
||||||
stickinessThreshold?: number
|
stickinessThreshold?: number
|
||||||
}
|
}
|
||||||
@@ -36,105 +35,166 @@ export function useScrollManagement(
|
|||||||
options?: UseScrollManagementOptions
|
options?: UseScrollManagementOptions
|
||||||
) {
|
) {
|
||||||
const scrollAreaRef = useRef<HTMLDivElement>(null)
|
const scrollAreaRef = useRef<HTMLDivElement>(null)
|
||||||
const [userHasScrolledAway, setUserHasScrolledAway] = useState(false)
|
const [isNearBottom, setIsNearBottom] = useState(true)
|
||||||
const programmaticScrollRef = useRef(false)
|
const [userHasScrolledDuringStream, setUserHasScrolledDuringStream] = useState(false)
|
||||||
|
const programmaticScrollInProgressRef = useRef(false)
|
||||||
const lastScrollTopRef = useRef(0)
|
const lastScrollTopRef = useRef(0)
|
||||||
|
const scrollBehavior: 'auto' | 'smooth' = options?.behavior ?? 'smooth'
|
||||||
const scrollBehavior = options?.behavior ?? 'smooth'
|
|
||||||
const stickinessThreshold = options?.stickinessThreshold ?? 100
|
const stickinessThreshold = options?.stickinessThreshold ?? 100
|
||||||
|
|
||||||
/** Scrolls the container to the bottom */
|
/**
|
||||||
|
* Scrolls the container to the bottom with smooth animation
|
||||||
|
*/
|
||||||
|
const getScrollContainer = useCallback((): HTMLElement | null => {
|
||||||
|
// Prefer the element with the ref (our scrollable div)
|
||||||
|
if (scrollAreaRef.current) return scrollAreaRef.current
|
||||||
|
return null
|
||||||
|
}, [])
|
||||||
|
|
||||||
const scrollToBottom = useCallback(() => {
|
const scrollToBottom = useCallback(() => {
|
||||||
const container = scrollAreaRef.current
|
const scrollContainer = getScrollContainer()
|
||||||
if (!container) return
|
if (!scrollContainer) return
|
||||||
|
|
||||||
programmaticScrollRef.current = true
|
|
||||||
container.scrollTo({ top: container.scrollHeight, behavior: scrollBehavior })
|
|
||||||
|
|
||||||
|
programmaticScrollInProgressRef.current = true
|
||||||
|
scrollContainer.scrollTo({
|
||||||
|
top: scrollContainer.scrollHeight,
|
||||||
|
behavior: scrollBehavior,
|
||||||
|
})
|
||||||
|
// Best-effort reset; not all browsers fire scrollend reliably
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
programmaticScrollRef.current = false
|
programmaticScrollInProgressRef.current = false
|
||||||
}, 200)
|
}, 200)
|
||||||
}, [scrollBehavior])
|
}, [getScrollContainer, scrollBehavior])
|
||||||
|
|
||||||
/** Handles scroll events to track user position */
|
/**
|
||||||
|
* Handles scroll events to track user position and show/hide scroll button
|
||||||
|
*/
|
||||||
const handleScroll = useCallback(() => {
|
const handleScroll = useCallback(() => {
|
||||||
const container = scrollAreaRef.current
|
const scrollContainer = getScrollContainer()
|
||||||
if (!container || programmaticScrollRef.current) return
|
if (!scrollContainer) return
|
||||||
|
|
||||||
const { scrollTop, scrollHeight, clientHeight } = container
|
if (programmaticScrollInProgressRef.current) {
|
||||||
|
// Ignore scrolls we initiated
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = scrollContainer
|
||||||
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
|
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
|
||||||
|
|
||||||
const nearBottom = distanceFromBottom <= stickinessThreshold
|
const nearBottom = distanceFromBottom <= stickinessThreshold
|
||||||
const delta = scrollTop - lastScrollTopRef.current
|
setIsNearBottom(nearBottom)
|
||||||
|
|
||||||
if (isSendingMessage) {
|
if (isSendingMessage) {
|
||||||
// User scrolled up during streaming - break away
|
const delta = scrollTop - lastScrollTopRef.current
|
||||||
if (delta < -2) {
|
const movedUp = delta < -2 // small hysteresis to avoid noise
|
||||||
setUserHasScrolledAway(true)
|
const movedDown = delta > 2
|
||||||
|
|
||||||
|
if (movedUp) {
|
||||||
|
// Any upward movement breaks away from sticky during streaming
|
||||||
|
setUserHasScrolledDuringStream(true)
|
||||||
}
|
}
|
||||||
// User scrolled back down to bottom - re-stick
|
|
||||||
if (userHasScrolledAway && delta > 2 && nearBottom) {
|
// If the user has broken away and scrolls back down to the bottom, re-stick
|
||||||
setUserHasScrolledAway(false)
|
if (userHasScrolledDuringStream && movedDown && nearBottom) {
|
||||||
|
setUserHasScrolledDuringStream(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track last scrollTop for direction detection
|
||||||
lastScrollTopRef.current = scrollTop
|
lastScrollTopRef.current = scrollTop
|
||||||
}, [isSendingMessage, userHasScrolledAway, stickinessThreshold])
|
}, [getScrollContainer, isSendingMessage, userHasScrolledDuringStream, stickinessThreshold])
|
||||||
|
|
||||||
/** Attaches scroll listener to container */
|
// Attach scroll listener
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = scrollAreaRef.current
|
const scrollContainer = getScrollContainer()
|
||||||
if (!container) return
|
if (!scrollContainer) return
|
||||||
|
|
||||||
container.addEventListener('scroll', handleScroll, { passive: true })
|
const handleUserScroll = () => {
|
||||||
lastScrollTopRef.current = container.scrollTop
|
handleScroll()
|
||||||
|
}
|
||||||
|
|
||||||
return () => container.removeEventListener('scroll', handleScroll)
|
scrollContainer.addEventListener('scroll', handleUserScroll, { passive: true })
|
||||||
}, [handleScroll])
|
|
||||||
|
|
||||||
/** Handles auto-scroll when new messages are added */
|
if ('onscrollend' in scrollContainer) {
|
||||||
|
scrollContainer.addEventListener('scrollend', handleScroll, { passive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize state
|
||||||
|
window.setTimeout(handleScroll, 100)
|
||||||
|
// Initialize last scroll position
|
||||||
|
lastScrollTopRef.current = scrollContainer.scrollTop
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
scrollContainer.removeEventListener('scroll', handleUserScroll)
|
||||||
|
if ('onscrollend' in scrollContainer) {
|
||||||
|
scrollContainer.removeEventListener('scrollend', handleScroll)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [getScrollContainer, handleScroll])
|
||||||
|
|
||||||
|
// Smart auto-scroll: only scroll if user hasn't intentionally scrolled up during streaming
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (messages.length === 0) return
|
if (messages.length === 0) return
|
||||||
|
|
||||||
const lastMessage = messages[messages.length - 1]
|
const lastMessage = messages[messages.length - 1]
|
||||||
const isUserMessage = lastMessage?.role === 'user'
|
const isNewUserMessage = lastMessage?.role === 'user'
|
||||||
|
|
||||||
// Always scroll for user messages, respect scroll state for assistant messages
|
const shouldAutoScroll =
|
||||||
if (isUserMessage) {
|
isNewUserMessage ||
|
||||||
setUserHasScrolledAway(false)
|
(isSendingMessage && !userHasScrolledDuringStream) ||
|
||||||
scrollToBottom()
|
(!isSendingMessage && isNearBottom)
|
||||||
} else if (!userHasScrolledAway) {
|
|
||||||
|
if (shouldAutoScroll) {
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
}
|
}
|
||||||
}, [messages, userHasScrolledAway, scrollToBottom])
|
}, [messages, isNearBottom, isSendingMessage, userHasScrolledDuringStream, scrollToBottom])
|
||||||
|
|
||||||
/** Resets scroll state when streaming completes */
|
// Reset user scroll state when streaming starts or when user sends a message
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isSendingMessage) {
|
const lastMessage = messages[messages.length - 1]
|
||||||
setUserHasScrolledAway(false)
|
if (lastMessage?.role === 'user') {
|
||||||
|
setUserHasScrolledDuringStream(false)
|
||||||
|
programmaticScrollInProgressRef.current = false
|
||||||
|
const scrollContainer = getScrollContainer()
|
||||||
|
if (scrollContainer) {
|
||||||
|
lastScrollTopRef.current = scrollContainer.scrollTop
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}, [messages, getScrollContainer])
|
||||||
|
|
||||||
|
// Reset user scroll state when streaming completes
|
||||||
|
const prevIsSendingRef = useRef(false)
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevIsSendingRef.current && !isSendingMessage) {
|
||||||
|
setUserHasScrolledDuringStream(false)
|
||||||
|
}
|
||||||
|
prevIsSendingRef.current = isSendingMessage
|
||||||
}, [isSendingMessage])
|
}, [isSendingMessage])
|
||||||
|
|
||||||
/** Keeps scroll pinned during streaming - uses interval, stops when user scrolls away */
|
// While streaming and not broken away, keep pinned to bottom
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Early return stops the interval when user scrolls away (state change re-runs effect)
|
if (!isSendingMessage || userHasScrolledDuringStream) return
|
||||||
if (!isSendingMessage || userHasScrolledAway) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const intervalId = window.setInterval(() => {
|
const intervalId = window.setInterval(() => {
|
||||||
const container = scrollAreaRef.current
|
const scrollContainer = getScrollContainer()
|
||||||
if (!container) return
|
if (!scrollContainer) return
|
||||||
|
|
||||||
const { scrollTop, scrollHeight, clientHeight } = container
|
const { scrollTop, scrollHeight, clientHeight } = scrollContainer
|
||||||
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
|
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
|
||||||
|
const nearBottom = distanceFromBottom <= stickinessThreshold
|
||||||
if (distanceFromBottom > 1) {
|
if (nearBottom) {
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
}
|
}
|
||||||
}, 100)
|
}, 100)
|
||||||
|
|
||||||
return () => window.clearInterval(intervalId)
|
return () => window.clearInterval(intervalId)
|
||||||
}, [isSendingMessage, userHasScrolledAway, scrollToBottom])
|
}, [
|
||||||
|
isSendingMessage,
|
||||||
|
userHasScrolledDuringStream,
|
||||||
|
getScrollContainer,
|
||||||
|
scrollToBottom,
|
||||||
|
stickinessThreshold,
|
||||||
|
])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
scrollAreaRef,
|
scrollAreaRef,
|
||||||
|
|||||||
@@ -1072,7 +1072,7 @@ export function AccessControl() {
|
|||||||
<ModalContent size='sm'>
|
<ModalContent size='sm'>
|
||||||
<ModalHeader>Unsaved Changes</ModalHeader>
|
<ModalHeader>Unsaved Changes</ModalHeader>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||||
You have unsaved changes. Do you want to save them before closing?
|
You have unsaved changes. Do you want to save them before closing?
|
||||||
</p>
|
</p>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export function CreateApiKeyModal({
|
|||||||
<ModalContent size='sm'>
|
<ModalContent size='sm'>
|
||||||
<ModalHeader>Create new API key</ModalHeader>
|
<ModalHeader>Create new API key</ModalHeader>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||||
{keyType === 'workspace'
|
{keyType === 'workspace'
|
||||||
? "This key will have access to all workflows in this workspace. Make sure to copy it after creation as you won't be able to see it again."
|
? "This key will have access to all workflows in this workspace. Make sure to copy it after creation as you won't be able to see it again."
|
||||||
: "This key will have access to your personal workflows. Make sure to copy it after creation as you won't be able to see it again."}
|
: "This key will have access to your personal workflows. Make sure to copy it after creation as you won't be able to see it again."}
|
||||||
@@ -218,7 +218,7 @@ export function CreateApiKeyModal({
|
|||||||
<ModalContent size='sm'>
|
<ModalContent size='sm'>
|
||||||
<ModalHeader>Your API key has been created</ModalHeader>
|
<ModalHeader>Your API key has been created</ModalHeader>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||||
This is the only time you will see your API key.{' '}
|
This is the only time you will see your API key.{' '}
|
||||||
<span className='font-semibold text-[var(--text-primary)]'>
|
<span className='font-semibold text-[var(--text-primary)]'>
|
||||||
Copy it now and store it securely.
|
Copy it now and store it securely.
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ export function BYOK() {
|
|||||||
)}
|
)}
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||||
This key will be used for all {PROVIDERS.find((p) => p.id === editingProvider)?.name}{' '}
|
This key will be used for all {PROVIDERS.find((p) => p.id === editingProvider)?.name}{' '}
|
||||||
requests in this workspace. Your key is encrypted and stored securely.
|
requests in this workspace. Your key is encrypted and stored securely.
|
||||||
</p>
|
</p>
|
||||||
@@ -308,7 +308,7 @@ export function BYOK() {
|
|||||||
<ModalContent size='sm'>
|
<ModalContent size='sm'>
|
||||||
<ModalHeader>Delete API Key</ModalHeader>
|
<ModalHeader>Delete API Key</ModalHeader>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||||
Are you sure you want to delete the{' '}
|
Are you sure you want to delete the{' '}
|
||||||
<span className='font-medium text-[var(--text-primary)]'>
|
<span className='font-medium text-[var(--text-primary)]'>
|
||||||
{PROVIDERS.find((p) => p.id === deleteConfirmProvider)?.name}
|
{PROVIDERS.find((p) => p.id === deleteConfirmProvider)?.name}
|
||||||
|
|||||||
@@ -214,7 +214,7 @@ export function Copilot() {
|
|||||||
<ModalContent size='sm'>
|
<ModalContent size='sm'>
|
||||||
<ModalHeader>Create new API key</ModalHeader>
|
<ModalHeader>Create new API key</ModalHeader>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||||
This key will allow access to Copilot features. Make sure to copy it after creation as
|
This key will allow access to Copilot features. Make sure to copy it after creation as
|
||||||
you won't be able to see it again.
|
you won't be able to see it again.
|
||||||
</p>
|
</p>
|
||||||
@@ -276,7 +276,7 @@ export function Copilot() {
|
|||||||
<ModalContent size='sm'>
|
<ModalContent size='sm'>
|
||||||
<ModalHeader>Your API key has been created</ModalHeader>
|
<ModalHeader>Your API key has been created</ModalHeader>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||||
This is the only time you will see your API key.{' '}
|
This is the only time you will see your API key.{' '}
|
||||||
<span className='font-semibold text-[var(--text-primary)]'>
|
<span className='font-semibold text-[var(--text-primary)]'>
|
||||||
Copy it now and store it securely.
|
Copy it now and store it securely.
|
||||||
|
|||||||
@@ -824,7 +824,7 @@ export function EnvironmentVariables({ registerBeforeLeaveHandler }: Environment
|
|||||||
<ModalContent size='sm'>
|
<ModalContent size='sm'>
|
||||||
<ModalHeader>Unsaved Changes</ModalHeader>
|
<ModalHeader>Unsaved Changes</ModalHeader>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||||
{hasConflicts || hasInvalidKeys
|
{hasConflicts || hasInvalidKeys
|
||||||
? `You have unsaved changes, but ${hasConflicts ? 'conflicts must be resolved' : 'invalid variable names must be fixed'} before saving. You can discard your changes to close the modal.`
|
? `You have unsaved changes, but ${hasConflicts ? 'conflicts must be resolved' : 'invalid variable names must be fixed'} before saving. You can discard your changes to close the modal.`
|
||||||
: 'You have unsaved changes. Do you want to save them before closing?'}
|
: 'You have unsaved changes. Do you want to save them before closing?'}
|
||||||
|
|||||||
@@ -603,7 +603,7 @@ export function General({ onOpenChange }: GeneralProps) {
|
|||||||
<ModalContent size='sm'>
|
<ModalContent size='sm'>
|
||||||
<ModalHeader>Reset Password</ModalHeader>
|
<ModalHeader>Reset Password</ModalHeader>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||||
A password reset link will be sent to{' '}
|
A password reset link will be sent to{' '}
|
||||||
<span className='font-medium text-[var(--text-primary)]'>{profile?.email}</span>.
|
<span className='font-medium text-[var(--text-primary)]'>{profile?.email}</span>.
|
||||||
Click the link in the email to create a new password.
|
Click the link in the email to create a new password.
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export function TeamSeats({
|
|||||||
<ModalContent size='sm'>
|
<ModalContent size='sm'>
|
||||||
<ModalHeader>{title}</ModalHeader>
|
<ModalHeader>{title}</ModalHeader>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<p className='text-[12px] text-[var(--text-secondary)]'>{description}</p>
|
<p className='text-[12px] text-[var(--text-muted)]'>{description}</p>
|
||||||
|
|
||||||
<div className='mt-[16px] flex flex-col gap-[4px]'>
|
<div className='mt-[16px] flex flex-col gap-[4px]'>
|
||||||
<Label htmlFor='seats' className='text-[12px]'>
|
<Label htmlFor='seats' className='text-[12px]'>
|
||||||
|
|||||||
@@ -25,11 +25,9 @@ const GRID_COLUMNS = 6
|
|||||||
function ColorGrid({
|
function ColorGrid({
|
||||||
hexInput,
|
hexInput,
|
||||||
setHexInput,
|
setHexInput,
|
||||||
onColorChange,
|
|
||||||
}: {
|
}: {
|
||||||
hexInput: string
|
hexInput: string
|
||||||
setHexInput: (color: string) => void
|
setHexInput: (color: string) => void
|
||||||
onColorChange?: (color: string) => void
|
|
||||||
}) {
|
}) {
|
||||||
const { isInFolder } = usePopoverContext()
|
const { isInFolder } = usePopoverContext()
|
||||||
const [focusedIndex, setFocusedIndex] = useState(-1)
|
const [focusedIndex, setFocusedIndex] = useState(-1)
|
||||||
@@ -74,9 +72,7 @@ function ColorGrid({
|
|||||||
case 'Enter':
|
case 'Enter':
|
||||||
case ' ':
|
case ' ':
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
|
||||||
setHexInput(WORKFLOW_COLORS[index].color)
|
setHexInput(WORKFLOW_COLORS[index].color)
|
||||||
onColorChange?.(WORKFLOW_COLORS[index].color)
|
|
||||||
return
|
return
|
||||||
default:
|
default:
|
||||||
return
|
return
|
||||||
@@ -87,7 +83,7 @@ function ColorGrid({
|
|||||||
buttonRefs.current[newIndex]?.focus()
|
buttonRefs.current[newIndex]?.focus()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[setHexInput, onColorChange]
|
[setHexInput]
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -109,10 +105,8 @@ function ColorGrid({
|
|||||||
onKeyDown={(e) => handleKeyDown(e, index)}
|
onKeyDown={(e) => handleKeyDown(e, index)}
|
||||||
onFocus={() => setFocusedIndex(index)}
|
onFocus={() => setFocusedIndex(index)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-[20px] w-[20px] rounded-[4px] outline-none ring-white ring-offset-0',
|
'h-[20px] w-[20px] rounded-[4px] focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-1 focus:ring-offset-[#1b1b1b]',
|
||||||
(focusedIndex === index ||
|
hexInput.toLowerCase() === color.toLowerCase() && 'ring-1 ring-white'
|
||||||
(focusedIndex === -1 && hexInput.toLowerCase() === color.toLowerCase())) &&
|
|
||||||
'ring-[1.5px]'
|
|
||||||
)}
|
)}
|
||||||
style={{ backgroundColor: color }}
|
style={{ backgroundColor: color }}
|
||||||
/>
|
/>
|
||||||
@@ -456,11 +450,7 @@ export function ContextMenu({
|
|||||||
>
|
>
|
||||||
<div className='flex w-[140px] flex-col gap-[8px] p-[2px]'>
|
<div className='flex w-[140px] flex-col gap-[8px] p-[2px]'>
|
||||||
{/* Preset colors with keyboard navigation */}
|
{/* Preset colors with keyboard navigation */}
|
||||||
<ColorGrid
|
<ColorGrid hexInput={hexInput} setHexInput={setHexInput} />
|
||||||
hexInput={hexInput}
|
|
||||||
setHexInput={setHexInput}
|
|
||||||
onColorChange={onColorChange}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Hex input */}
|
{/* Hex input */}
|
||||||
<div className='flex items-center gap-[4px]'>
|
<div className='flex items-center gap-[4px]'>
|
||||||
|
|||||||
@@ -459,7 +459,6 @@ export function WorkspaceHeader({
|
|||||||
value={editingName}
|
value={editingName}
|
||||||
onChange={(e) => setEditingName(e.target.value)}
|
onChange={(e) => setEditingName(e.target.value)}
|
||||||
onKeyDown={async (e) => {
|
onKeyDown={async (e) => {
|
||||||
e.stopPropagation()
|
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setIsListRenaming(true)
|
setIsListRenaming(true)
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* Code editor syntax token theme.
|
* Code editor syntax token theme.
|
||||||
* Cursor/VS Code base colors with Sim's vibrant saturation.
|
* Light mode: Vibrant colors matching dark mode's aesthetic quality.
|
||||||
* Colors aligned to Sim brand where applicable.
|
* Dark mode: VSCode Dark+ inspired colors with deep, vibrant palette.
|
||||||
* Applied to elements with .code-editor-theme class.
|
* Applied to elements with .code-editor-theme class.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Light mode token colors - Cursor style with Sim vibrancy
|
* Light mode token colors (default) - vibrant palette
|
||||||
*/
|
*/
|
||||||
.code-editor-theme .token.comment,
|
.code-editor-theme .token.comment,
|
||||||
.code-editor-theme .token.block-comment,
|
.code-editor-theme .token.block-comment,
|
||||||
.code-editor-theme .token.prolog,
|
.code-editor-theme .token.prolog,
|
||||||
.code-editor-theme .token.doctype,
|
.code-editor-theme .token.doctype,
|
||||||
.code-editor-theme .token.cdata {
|
.code-editor-theme .token.cdata {
|
||||||
color: #16a34a !important;
|
color: #2e7d32 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.code-editor-theme .token.punctuation {
|
.code-editor-theme .token.punctuation {
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
.code-editor-theme .token.boolean,
|
.code-editor-theme .token.boolean,
|
||||||
.code-editor-theme .token.number,
|
.code-editor-theme .token.number,
|
||||||
.code-editor-theme .token.constant {
|
.code-editor-theme .token.constant {
|
||||||
color: #16a34a !important;
|
color: #b45309 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.code-editor-theme .token.string,
|
.code-editor-theme .token.string,
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
.code-editor-theme .token.atrule,
|
.code-editor-theme .token.atrule,
|
||||||
.code-editor-theme .token.attr-value,
|
.code-editor-theme .token.attr-value,
|
||||||
.code-editor-theme .token.keyword {
|
.code-editor-theme .token.keyword {
|
||||||
color: #2563eb !important;
|
color: #9333ea !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.code-editor-theme .token.function,
|
.code-editor-theme .token.function,
|
||||||
@@ -76,68 +76,68 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dark mode token colors - Cursor style with Sim vibrancy
|
* Dark mode token colors
|
||||||
*/
|
*/
|
||||||
.dark .code-editor-theme .token.comment,
|
.dark .code-editor-theme .token.comment,
|
||||||
.dark .code-editor-theme .token.block-comment,
|
.dark .code-editor-theme .token.block-comment,
|
||||||
.dark .code-editor-theme .token.prolog,
|
.dark .code-editor-theme .token.prolog,
|
||||||
.dark .code-editor-theme .token.doctype,
|
.dark .code-editor-theme .token.doctype,
|
||||||
.dark .code-editor-theme .token.cdata {
|
.dark .code-editor-theme .token.cdata {
|
||||||
color: #6ec97d !important;
|
color: #8bc985 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .code-editor-theme .token.punctuation {
|
.dark .code-editor-theme .token.punctuation {
|
||||||
color: #d4d4d4 !important;
|
color: #eeeeee !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .code-editor-theme .token.property,
|
.dark .code-editor-theme .token.property,
|
||||||
.dark .code-editor-theme .token.attr-name,
|
.dark .code-editor-theme .token.attr-name,
|
||||||
.dark .code-editor-theme .token.variable {
|
.dark .code-editor-theme .token.variable {
|
||||||
color: #4fc3f7 !important;
|
color: #5fc9cb !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .code-editor-theme .token.tag,
|
.dark .code-editor-theme .token.tag,
|
||||||
.dark .code-editor-theme .token.boolean,
|
.dark .code-editor-theme .token.boolean,
|
||||||
.dark .code-editor-theme .token.number,
|
.dark .code-editor-theme .token.number,
|
||||||
.dark .code-editor-theme .token.constant {
|
.dark .code-editor-theme .token.constant {
|
||||||
color: #a5d6a7 !important;
|
color: #ffc857 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .code-editor-theme .token.string,
|
.dark .code-editor-theme .token.string,
|
||||||
.dark .code-editor-theme .token.char,
|
.dark .code-editor-theme .token.char,
|
||||||
.dark .code-editor-theme .token.builtin,
|
.dark .code-editor-theme .token.builtin,
|
||||||
.dark .code-editor-theme .token.inserted {
|
.dark .code-editor-theme .token.inserted {
|
||||||
color: #f39c6b !important;
|
color: #ff6b6b !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .code-editor-theme .token.operator,
|
.dark .code-editor-theme .token.operator,
|
||||||
.dark .code-editor-theme .token.entity,
|
.dark .code-editor-theme .token.entity,
|
||||||
.dark .code-editor-theme .token.url {
|
.dark .code-editor-theme .token.url {
|
||||||
color: #d4d4d4 !important;
|
color: #eeeeee !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .code-editor-theme .token.atrule,
|
.dark .code-editor-theme .token.atrule,
|
||||||
.dark .code-editor-theme .token.attr-value,
|
.dark .code-editor-theme .token.attr-value,
|
||||||
.dark .code-editor-theme .token.keyword {
|
.dark .code-editor-theme .token.keyword {
|
||||||
color: #4db8ff !important;
|
color: #d896d8 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .code-editor-theme .token.function,
|
.dark .code-editor-theme .token.function,
|
||||||
.dark .code-editor-theme .token.class-name {
|
.dark .code-editor-theme .token.class-name {
|
||||||
color: #fbbf24 !important;
|
color: #ffc857 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .code-editor-theme .token.regex,
|
.dark .code-editor-theme .token.regex,
|
||||||
.dark .code-editor-theme .token.important {
|
.dark .code-editor-theme .token.important {
|
||||||
color: #f87171 !important;
|
color: #ff6b6b !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .code-editor-theme .token.symbol {
|
.dark .code-editor-theme .token.symbol {
|
||||||
color: #d4d4d4 !important;
|
color: #eeeeee !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .code-editor-theme .token.deleted {
|
.dark .code-editor-theme .token.deleted {
|
||||||
color: #f87171 !important;
|
color: #ff6b6b !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Blue accents for <var> and {{ENV}} placeholders - dark mode */
|
/* Blue accents for <var> and {{ENV}} placeholders - dark mode */
|
||||||
|
|||||||
@@ -460,13 +460,6 @@ const PopoverContent = React.forwardRef<
|
|||||||
const content = contentRef.current
|
const content = contentRef.current
|
||||||
if (!content) return
|
if (!content) return
|
||||||
|
|
||||||
const activeElement = document.activeElement
|
|
||||||
const isInputFocused =
|
|
||||||
activeElement instanceof HTMLInputElement ||
|
|
||||||
activeElement instanceof HTMLTextAreaElement ||
|
|
||||||
activeElement?.getAttribute('contenteditable') === 'true'
|
|
||||||
if (isInputFocused) return
|
|
||||||
|
|
||||||
const items = content.querySelectorAll<HTMLElement>(
|
const items = content.querySelectorAll<HTMLElement>(
|
||||||
'[role="menuitem"]:not([aria-disabled="true"])'
|
'[role="menuitem"]:not([aria-disabled="true"])'
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { keepPreviousData, useQuery } from '@tanstack/react-query'
|
||||||
import type {
|
import type {
|
||||||
ChunkData,
|
ChunkData,
|
||||||
ChunksPagination,
|
ChunksPagination,
|
||||||
@@ -332,628 +332,3 @@ export function useDocumentChunkSearchQuery(
|
|||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateChunkParams {
|
|
||||||
knowledgeBaseId: string
|
|
||||||
documentId: string
|
|
||||||
chunkId: string
|
|
||||||
content?: string
|
|
||||||
enabled?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateChunk({
|
|
||||||
knowledgeBaseId,
|
|
||||||
documentId,
|
|
||||||
chunkId,
|
|
||||||
content,
|
|
||||||
enabled,
|
|
||||||
}: UpdateChunkParams): Promise<ChunkData> {
|
|
||||||
const body: Record<string, unknown> = {}
|
|
||||||
if (content !== undefined) body.content = content
|
|
||||||
if (enabled !== undefined) body.enabled = enabled
|
|
||||||
|
|
||||||
const response = await fetch(
|
|
||||||
`/api/knowledge/${knowledgeBaseId}/documents/${documentId}/chunks/${chunkId}`,
|
|
||||||
{
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const result = await response.json()
|
|
||||||
throw new Error(result.error || 'Failed to update chunk')
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json()
|
|
||||||
if (!result?.success) {
|
|
||||||
throw new Error(result?.error || 'Failed to update chunk')
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.data
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateChunk() {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: updateChunk,
|
|
||||||
onSuccess: (_, { knowledgeBaseId, documentId }) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: knowledgeKeys.detail(knowledgeBaseId),
|
|
||||||
})
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: knowledgeKeys.document(knowledgeBaseId, documentId),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeleteChunkParams {
|
|
||||||
knowledgeBaseId: string
|
|
||||||
documentId: string
|
|
||||||
chunkId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteChunk({
|
|
||||||
knowledgeBaseId,
|
|
||||||
documentId,
|
|
||||||
chunkId,
|
|
||||||
}: DeleteChunkParams): Promise<void> {
|
|
||||||
const response = await fetch(
|
|
||||||
`/api/knowledge/${knowledgeBaseId}/documents/${documentId}/chunks/${chunkId}`,
|
|
||||||
{ method: 'DELETE' }
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const result = await response.json()
|
|
||||||
throw new Error(result.error || 'Failed to delete chunk')
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json()
|
|
||||||
if (!result?.success) {
|
|
||||||
throw new Error(result?.error || 'Failed to delete chunk')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteChunk() {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: deleteChunk,
|
|
||||||
onSuccess: (_, { knowledgeBaseId, documentId }) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: knowledgeKeys.detail(knowledgeBaseId),
|
|
||||||
})
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: knowledgeKeys.document(knowledgeBaseId, documentId),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateChunkParams {
|
|
||||||
knowledgeBaseId: string
|
|
||||||
documentId: string
|
|
||||||
content: string
|
|
||||||
enabled?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createChunk({
|
|
||||||
knowledgeBaseId,
|
|
||||||
documentId,
|
|
||||||
content,
|
|
||||||
enabled = true,
|
|
||||||
}: CreateChunkParams): Promise<ChunkData> {
|
|
||||||
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/documents/${documentId}/chunks`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ content, enabled }),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const result = await response.json()
|
|
||||||
throw new Error(result.error || 'Failed to create chunk')
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json()
|
|
||||||
if (!result?.success || !result?.data) {
|
|
||||||
throw new Error(result?.error || 'Failed to create chunk')
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.data
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCreateChunk() {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: createChunk,
|
|
||||||
onSuccess: (_, { knowledgeBaseId, documentId }) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: knowledgeKeys.detail(knowledgeBaseId),
|
|
||||||
})
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: knowledgeKeys.document(knowledgeBaseId, documentId),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateDocumentParams {
|
|
||||||
knowledgeBaseId: string
|
|
||||||
documentId: string
|
|
||||||
updates: {
|
|
||||||
enabled?: boolean
|
|
||||||
filename?: string
|
|
||||||
retryProcessing?: boolean
|
|
||||||
markFailedDueToTimeout?: boolean
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateDocument({
|
|
||||||
knowledgeBaseId,
|
|
||||||
documentId,
|
|
||||||
updates,
|
|
||||||
}: UpdateDocumentParams): Promise<DocumentData> {
|
|
||||||
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/documents/${documentId}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(updates),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const result = await response.json()
|
|
||||||
throw new Error(result.error || 'Failed to update document')
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json()
|
|
||||||
if (!result?.success) {
|
|
||||||
throw new Error(result?.error || 'Failed to update document')
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.data
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateDocument() {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: updateDocument,
|
|
||||||
onSuccess: (_, { knowledgeBaseId, documentId }) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: knowledgeKeys.detail(knowledgeBaseId),
|
|
||||||
})
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: knowledgeKeys.document(knowledgeBaseId, documentId),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeleteDocumentParams {
|
|
||||||
knowledgeBaseId: string
|
|
||||||
documentId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteDocument({
|
|
||||||
knowledgeBaseId,
|
|
||||||
documentId,
|
|
||||||
}: DeleteDocumentParams): Promise<void> {
|
|
||||||
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/documents/${documentId}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const result = await response.json()
|
|
||||||
throw new Error(result.error || 'Failed to delete document')
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json()
|
|
||||||
if (!result?.success) {
|
|
||||||
throw new Error(result?.error || 'Failed to delete document')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteDocument() {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: deleteDocument,
|
|
||||||
onSuccess: (_, { knowledgeBaseId }) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: knowledgeKeys.detail(knowledgeBaseId),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BulkDocumentOperationParams {
|
|
||||||
knowledgeBaseId: string
|
|
||||||
operation: 'enable' | 'disable' | 'delete'
|
|
||||||
documentIds: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BulkDocumentOperationResult {
|
|
||||||
successCount: number
|
|
||||||
failedCount: number
|
|
||||||
updatedDocuments?: Array<{ id: string; enabled: boolean }>
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function bulkDocumentOperation({
|
|
||||||
knowledgeBaseId,
|
|
||||||
operation,
|
|
||||||
documentIds,
|
|
||||||
}: BulkDocumentOperationParams): Promise<BulkDocumentOperationResult> {
|
|
||||||
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/documents`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ operation, documentIds }),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const result = await response.json()
|
|
||||||
throw new Error(result.error || `Failed to ${operation} documents`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json()
|
|
||||||
if (!result?.success) {
|
|
||||||
throw new Error(result?.error || `Failed to ${operation} documents`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.data
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useBulkDocumentOperation() {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: bulkDocumentOperation,
|
|
||||||
onSuccess: (_, { knowledgeBaseId }) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: knowledgeKeys.detail(knowledgeBaseId),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateKnowledgeBaseParams {
|
|
||||||
name: string
|
|
||||||
description?: string
|
|
||||||
workspaceId: string
|
|
||||||
chunkingConfig: {
|
|
||||||
maxSize: number
|
|
||||||
minSize: number
|
|
||||||
overlap: number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createKnowledgeBase(
|
|
||||||
params: CreateKnowledgeBaseParams
|
|
||||||
): Promise<KnowledgeBaseData> {
|
|
||||||
const response = await fetch('/api/knowledge', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(params),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const result = await response.json()
|
|
||||||
throw new Error(result.error || 'Failed to create knowledge base')
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json()
|
|
||||||
if (!result?.success || !result?.data) {
|
|
||||||
throw new Error(result?.error || 'Failed to create knowledge base')
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.data
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCreateKnowledgeBase(workspaceId?: string) {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: createKnowledgeBase,
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: knowledgeKeys.list(workspaceId),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateKnowledgeBaseParams {
|
|
||||||
knowledgeBaseId: string
|
|
||||||
updates: {
|
|
||||||
name?: string
|
|
||||||
description?: string
|
|
||||||
workspaceId?: string | null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateKnowledgeBase({
|
|
||||||
knowledgeBaseId,
|
|
||||||
updates,
|
|
||||||
}: UpdateKnowledgeBaseParams): Promise<KnowledgeBaseData> {
|
|
||||||
const response = await fetch(`/api/knowledge/${knowledgeBaseId}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(updates),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const result = await response.json()
|
|
||||||
throw new Error(result.error || 'Failed to update knowledge base')
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json()
|
|
||||||
if (!result?.success) {
|
|
||||||
throw new Error(result?.error || 'Failed to update knowledge base')
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.data
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateKnowledgeBase(workspaceId?: string) {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: updateKnowledgeBase,
|
|
||||||
onSuccess: (_, { knowledgeBaseId }) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: knowledgeKeys.detail(knowledgeBaseId),
|
|
||||||
})
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: knowledgeKeys.list(workspaceId),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeleteKnowledgeBaseParams {
|
|
||||||
knowledgeBaseId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteKnowledgeBase({
|
|
||||||
knowledgeBaseId,
|
|
||||||
}: DeleteKnowledgeBaseParams): Promise<void> {
|
|
||||||
const response = await fetch(`/api/knowledge/${knowledgeBaseId}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const result = await response.json()
|
|
||||||
throw new Error(result.error || 'Failed to delete knowledge base')
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json()
|
|
||||||
if (!result?.success) {
|
|
||||||
throw new Error(result?.error || 'Failed to delete knowledge base')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteKnowledgeBase(workspaceId?: string) {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: deleteKnowledgeBase,
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: knowledgeKeys.list(workspaceId),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BulkChunkOperationParams {
|
|
||||||
knowledgeBaseId: string
|
|
||||||
documentId: string
|
|
||||||
operation: 'enable' | 'disable' | 'delete'
|
|
||||||
chunkIds: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BulkChunkOperationResult {
|
|
||||||
operation: string
|
|
||||||
successCount: number
|
|
||||||
errorCount: number
|
|
||||||
processed: number
|
|
||||||
errors: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function bulkChunkOperation({
|
|
||||||
knowledgeBaseId,
|
|
||||||
documentId,
|
|
||||||
operation,
|
|
||||||
chunkIds,
|
|
||||||
}: BulkChunkOperationParams): Promise<BulkChunkOperationResult> {
|
|
||||||
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/documents/${documentId}/chunks`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ operation, chunkIds }),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const result = await response.json()
|
|
||||||
throw new Error(result.error || `Failed to ${operation} chunks`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json()
|
|
||||||
if (!result?.success) {
|
|
||||||
throw new Error(result?.error || `Failed to ${operation} chunks`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.data
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useBulkChunkOperation() {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: bulkChunkOperation,
|
|
||||||
onSuccess: (_, { knowledgeBaseId, documentId }) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: knowledgeKeys.detail(knowledgeBaseId),
|
|
||||||
})
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: knowledgeKeys.document(knowledgeBaseId, documentId),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateDocumentTagsParams {
|
|
||||||
knowledgeBaseId: string
|
|
||||||
documentId: string
|
|
||||||
tags: Record<string, string>
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateDocumentTags({
|
|
||||||
knowledgeBaseId,
|
|
||||||
documentId,
|
|
||||||
tags,
|
|
||||||
}: UpdateDocumentTagsParams): Promise<DocumentData> {
|
|
||||||
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/documents/${documentId}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(tags),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const result = await response.json()
|
|
||||||
throw new Error(result.error || 'Failed to update document tags')
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json()
|
|
||||||
if (!result?.success) {
|
|
||||||
throw new Error(result?.error || 'Failed to update document tags')
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.data
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpdateDocumentTags() {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: updateDocumentTags,
|
|
||||||
onSuccess: (_, { knowledgeBaseId, documentId }) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: knowledgeKeys.detail(knowledgeBaseId),
|
|
||||||
})
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: knowledgeKeys.document(knowledgeBaseId, documentId),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TagDefinitionData {
|
|
||||||
id: string
|
|
||||||
tagSlot: string
|
|
||||||
displayName: string
|
|
||||||
fieldType: string
|
|
||||||
createdAt: string
|
|
||||||
updatedAt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateTagDefinitionParams {
|
|
||||||
knowledgeBaseId: string
|
|
||||||
displayName: string
|
|
||||||
fieldType: string
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchNextAvailableSlot(knowledgeBaseId: string, fieldType: string): Promise<string> {
|
|
||||||
const response = await fetch(
|
|
||||||
`/api/knowledge/${knowledgeBaseId}/next-available-slot?fieldType=${fieldType}`
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to get available slot')
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json()
|
|
||||||
if (!result.success || !result.data?.nextAvailableSlot) {
|
|
||||||
throw new Error('No available tag slots for this field type')
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.data.nextAvailableSlot
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createTagDefinition({
|
|
||||||
knowledgeBaseId,
|
|
||||||
displayName,
|
|
||||||
fieldType,
|
|
||||||
}: CreateTagDefinitionParams): Promise<TagDefinitionData> {
|
|
||||||
const tagSlot = await fetchNextAvailableSlot(knowledgeBaseId, fieldType)
|
|
||||||
|
|
||||||
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/tag-definitions`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ tagSlot, displayName, fieldType }),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const result = await response.json()
|
|
||||||
throw new Error(result.error || 'Failed to create tag definition')
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json()
|
|
||||||
if (!result?.success || !result?.data) {
|
|
||||||
throw new Error(result?.error || 'Failed to create tag definition')
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.data
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCreateTagDefinition() {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: createTagDefinition,
|
|
||||||
onSuccess: (_, { knowledgeBaseId }) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: knowledgeKeys.detail(knowledgeBaseId),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeleteTagDefinitionParams {
|
|
||||||
knowledgeBaseId: string
|
|
||||||
tagDefinitionId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteTagDefinition({
|
|
||||||
knowledgeBaseId,
|
|
||||||
tagDefinitionId,
|
|
||||||
}: DeleteTagDefinitionParams): Promise<void> {
|
|
||||||
const response = await fetch(
|
|
||||||
`/api/knowledge/${knowledgeBaseId}/tag-definitions/${tagDefinitionId}`,
|
|
||||||
{ method: 'DELETE' }
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const result = await response.json()
|
|
||||||
throw new Error(result.error || 'Failed to delete tag definition')
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json()
|
|
||||||
if (!result?.success) {
|
|
||||||
throw new Error(result?.error || 'Failed to delete tag definition')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteTagDefinition() {
|
|
||||||
const queryClient = useQueryClient()
|
|
||||||
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: deleteTagDefinition,
|
|
||||||
onSuccess: (_, { knowledgeBaseId }) => {
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: knowledgeKeys.detail(knowledgeBaseId),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -26,20 +26,21 @@ export class GetExamplesRagClientTool extends BaseClientTool {
|
|||||||
getDynamicText: (params, state) => {
|
getDynamicText: (params, state) => {
|
||||||
if (params?.query && typeof params.query === 'string') {
|
if (params?.query && typeof params.query === 'string') {
|
||||||
const query = params.query
|
const query = params.query
|
||||||
|
const truncated = query.length > 40 ? `${query.slice(0, 40)}...` : query
|
||||||
|
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case ClientToolCallState.success:
|
case ClientToolCallState.success:
|
||||||
return `Found examples for ${query}`
|
return `Found examples for ${truncated}`
|
||||||
case ClientToolCallState.executing:
|
case ClientToolCallState.executing:
|
||||||
case ClientToolCallState.generating:
|
case ClientToolCallState.generating:
|
||||||
case ClientToolCallState.pending:
|
case ClientToolCallState.pending:
|
||||||
return `Searching examples for ${query}`
|
return `Searching examples for ${truncated}`
|
||||||
case ClientToolCallState.error:
|
case ClientToolCallState.error:
|
||||||
return `Failed to find examples for ${query}`
|
return `Failed to find examples for ${truncated}`
|
||||||
case ClientToolCallState.aborted:
|
case ClientToolCallState.aborted:
|
||||||
return `Aborted searching examples for ${query}`
|
return `Aborted searching examples for ${truncated}`
|
||||||
case ClientToolCallState.rejected:
|
case ClientToolCallState.rejected:
|
||||||
return `Skipped searching examples for ${query}`
|
return `Skipped searching examples for ${truncated}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return undefined
|
return undefined
|
||||||
|
|||||||
@@ -32,20 +32,21 @@ export class GetOperationsExamplesClientTool extends BaseClientTool {
|
|||||||
getDynamicText: (params, state) => {
|
getDynamicText: (params, state) => {
|
||||||
if (params?.query && typeof params.query === 'string') {
|
if (params?.query && typeof params.query === 'string') {
|
||||||
const query = params.query
|
const query = params.query
|
||||||
|
const truncated = query.length > 40 ? `${query.slice(0, 40)}...` : query
|
||||||
|
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case ClientToolCallState.success:
|
case ClientToolCallState.success:
|
||||||
return `Designed ${query}`
|
return `Designed ${truncated}`
|
||||||
case ClientToolCallState.executing:
|
case ClientToolCallState.executing:
|
||||||
case ClientToolCallState.generating:
|
case ClientToolCallState.generating:
|
||||||
case ClientToolCallState.pending:
|
case ClientToolCallState.pending:
|
||||||
return `Designing ${query}`
|
return `Designing ${truncated}`
|
||||||
case ClientToolCallState.error:
|
case ClientToolCallState.error:
|
||||||
return `Failed to design ${query}`
|
return `Failed to design ${truncated}`
|
||||||
case ClientToolCallState.aborted:
|
case ClientToolCallState.aborted:
|
||||||
return `Aborted designing ${query}`
|
return `Aborted designing ${truncated}`
|
||||||
case ClientToolCallState.rejected:
|
case ClientToolCallState.rejected:
|
||||||
return `Skipped designing ${query}`
|
return `Skipped designing ${truncated}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return undefined
|
return undefined
|
||||||
|
|||||||
@@ -26,20 +26,21 @@ export class CrawlWebsiteClientTool extends BaseClientTool {
|
|||||||
getDynamicText: (params, state) => {
|
getDynamicText: (params, state) => {
|
||||||
if (params?.url && typeof params.url === 'string') {
|
if (params?.url && typeof params.url === 'string') {
|
||||||
const url = params.url
|
const url = params.url
|
||||||
|
const truncated = url.length > 50 ? `${url.slice(0, 50)}...` : url
|
||||||
|
|
||||||
switch (state) {
|
switch (state) {
|
||||||
case ClientToolCallState.success:
|
case ClientToolCallState.success:
|
||||||
return `Crawled ${url}`
|
return `Crawled ${truncated}`
|
||||||
case ClientToolCallState.executing:
|
case ClientToolCallState.executing:
|
||||||
case ClientToolCallState.generating:
|
case ClientToolCallState.generating:
|
||||||
case ClientToolCallState.pending:
|
case ClientToolCallState.pending:
|
||||||
return `Crawling ${url}`
|
return `Crawling ${truncated}`
|
||||||
case ClientToolCallState.error:
|
case ClientToolCallState.error:
|
||||||
return `Failed to crawl ${url}`
|
return `Failed to crawl ${truncated}`
|
||||||
case ClientToolCallState.aborted:
|
case ClientToolCallState.aborted:
|
||||||
return `Aborted crawling ${url}`
|
return `Aborted crawling ${truncated}`
|
||||||
case ClientToolCallState.rejected:
|
case ClientToolCallState.rejected:
|
||||||
return `Skipped crawling ${url}`
|
return `Skipped crawling ${truncated}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return undefined
|
return undefined
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user