mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-20 04:17:57 -05:00
Compare commits
3 Commits
v0.5.64
...
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.
|
||||
|
||||
## 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
|
||||
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 { getSession } from '@/lib/auth'
|
||||
|
||||
const logger = createLogger('SSOProvidersRoute')
|
||||
const logger = createLogger('SSO-Providers')
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { hasSSOAccess } from '@/lib/billing'
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import { REDACTED_MARKER } from '@/lib/core/security/redaction'
|
||||
|
||||
const logger = createLogger('SSORegisterRoute')
|
||||
const logger = createLogger('SSO-Register')
|
||||
|
||||
const mappingSchema = z
|
||||
.object({
|
||||
@@ -43,10 +43,6 @@ const ssoRegistrationSchema = z.discriminatedUnion('providerType', [
|
||||
])
|
||||
.default(['openid', 'profile', 'email']),
|
||||
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({
|
||||
providerType: z.literal('saml'),
|
||||
@@ -68,10 +64,12 @@ const ssoRegistrationSchema = z.discriminatedUnion('providerType', [
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// SSO plugin must be enabled in Better Auth
|
||||
if (!env.SSO_ENABLED) {
|
||||
return NextResponse.json({ error: 'SSO is not enabled' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Check plan access (enterprise) or env var override
|
||||
const session = await getSession()
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
@@ -118,16 +116,7 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
if (providerType === 'oidc') {
|
||||
const {
|
||||
clientId,
|
||||
clientSecret,
|
||||
scopes,
|
||||
pkce,
|
||||
authorizationEndpoint,
|
||||
tokenEndpoint,
|
||||
userInfoEndpoint,
|
||||
jwksEndpoint,
|
||||
} = body
|
||||
const { clientId, clientSecret, scopes, pkce } = body
|
||||
|
||||
const oidcConfig: any = {
|
||||
clientId,
|
||||
@@ -138,102 +127,48 @@ export async function POST(request: NextRequest) {
|
||||
pkce: pkce ?? true,
|
||||
}
|
||||
|
||||
oidcConfig.authorizationEndpoint = authorizationEndpoint
|
||||
oidcConfig.tokenEndpoint = tokenEndpoint
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
// Add manual endpoints for providers that might need them
|
||||
// Common patterns for OIDC providers that don't support discovery properly
|
||||
if (
|
||||
!oidcConfig.authorizationEndpoint ||
|
||||
!oidcConfig.tokenEndpoint ||
|
||||
!oidcConfig.jwksEndpoint
|
||||
issuer.includes('okta.com') ||
|
||||
issuer.includes('auth0.com') ||
|
||||
issuer.includes('identityserver')
|
||||
) {
|
||||
const missing: string[] = []
|
||||
if (!oidcConfig.authorizationEndpoint) missing.push('authorizationEndpoint')
|
||||
if (!oidcConfig.tokenEndpoint) missing.push('tokenEndpoint')
|
||||
if (!oidcConfig.jwksEndpoint) missing.push('jwksEndpoint')
|
||||
const baseUrl = issuer.includes('/oauth2/default')
|
||||
? issuer.replace('/oauth2/default', '')
|
||||
: issuer.replace('/oauth', '').replace('/v2.0', '').replace('/oauth2', '')
|
||||
|
||||
logger.error('Missing required OIDC endpoints after discovery merge', {
|
||||
missing,
|
||||
authorizationEndpoint: oidcConfig.authorizationEndpoint,
|
||||
tokenEndpoint: oidcConfig.tokenEndpoint,
|
||||
jwksEndpoint: oidcConfig.jwksEndpoint,
|
||||
// Okta-style endpoints
|
||||
if (issuer.includes('okta.com')) {
|
||||
oidcConfig.authorizationEndpoint = `${baseUrl}/oauth2/default/v1/authorize`
|
||||
oidcConfig.tokenEndpoint = `${baseUrl}/oauth2/default/v1/token`
|
||||
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
|
||||
|
||||
@@ -550,8 +550,6 @@ export interface AdminUserBilling {
|
||||
totalWebhookTriggers: number
|
||||
totalScheduledExecutions: number
|
||||
totalChatExecutions: number
|
||||
totalMcpExecutions: number
|
||||
totalA2aExecutions: number
|
||||
totalTokensUsed: number
|
||||
totalCost: string
|
||||
currentUsageLimit: string | null
|
||||
|
||||
@@ -97,8 +97,6 @@ export const GET = withAdminAuthParams<RouteParams>(async (_, context) => {
|
||||
totalWebhookTriggers: stats?.totalWebhookTriggers ?? 0,
|
||||
totalScheduledExecutions: stats?.totalScheduledExecutions ?? 0,
|
||||
totalChatExecutions: stats?.totalChatExecutions ?? 0,
|
||||
totalMcpExecutions: stats?.totalMcpExecutions ?? 0,
|
||||
totalA2aExecutions: stats?.totalA2aExecutions ?? 0,
|
||||
totalTokensUsed: stats?.totalTokensUsed ?? 0,
|
||||
totalCost: stats?.totalCost ?? '0',
|
||||
currentUsageLimit: stats?.currentUsageLimit ?? null,
|
||||
|
||||
@@ -19,7 +19,7 @@ export interface RateLimitResult {
|
||||
|
||||
export async function checkRateLimit(
|
||||
request: NextRequest,
|
||||
endpoint: 'logs' | 'logs-detail' | 'workflows' | 'workflow-detail' = 'logs'
|
||||
endpoint: 'logs' | 'logs-detail' = 'logs'
|
||||
): Promise<RateLimitResult> {
|
||||
try {
|
||||
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 { createLogger } from '@sim/logger'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Button,
|
||||
Label,
|
||||
@@ -13,7 +14,7 @@ import {
|
||||
Textarea,
|
||||
} from '@/components/emcn'
|
||||
import type { DocumentData } from '@/lib/knowledge/types'
|
||||
import { useCreateChunk } from '@/hooks/queries/knowledge'
|
||||
import { knowledgeKeys } from '@/hooks/queries/knowledge'
|
||||
|
||||
const logger = createLogger('CreateChunkModal')
|
||||
|
||||
@@ -30,20 +31,16 @@ export function CreateChunkModal({
|
||||
document,
|
||||
knowledgeBaseId,
|
||||
}: CreateChunkModalProps) {
|
||||
const {
|
||||
mutate: createChunk,
|
||||
isPending: isCreating,
|
||||
error: mutationError,
|
||||
reset: resetMutation,
|
||||
} = useCreateChunk()
|
||||
const queryClient = useQueryClient()
|
||||
const [content, setContent] = useState('')
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false)
|
||||
const isProcessingRef = useRef(false)
|
||||
|
||||
const error = mutationError?.message ?? null
|
||||
const hasUnsavedChanges = content.trim().length > 0
|
||||
|
||||
const handleCreateChunk = () => {
|
||||
const handleCreateChunk = async () => {
|
||||
if (!document || content.trim().length === 0 || isProcessingRef.current) {
|
||||
if (isProcessingRef.current) {
|
||||
logger.warn('Chunk creation already in progress, ignoring duplicate request')
|
||||
@@ -51,32 +48,57 @@ export function CreateChunkModal({
|
||||
return
|
||||
}
|
||||
|
||||
isProcessingRef.current = true
|
||||
try {
|
||||
isProcessingRef.current = true
|
||||
setIsCreating(true)
|
||||
setError(null)
|
||||
|
||||
createChunk(
|
||||
{
|
||||
knowledgeBaseId,
|
||||
documentId: document.id,
|
||||
content: content.trim(),
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
isProcessingRef.current = false
|
||||
onClose()
|
||||
},
|
||||
onError: () => {
|
||||
isProcessingRef.current = false
|
||||
},
|
||||
const response = await fetch(
|
||||
`/api/knowledge/${knowledgeBaseId}/documents/${document.id}/chunks`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content: content.trim(),
|
||||
enabled: true,
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
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 = () => {
|
||||
onOpenChange(false)
|
||||
setContent('')
|
||||
setError(null)
|
||||
setShowUnsavedChangesAlert(false)
|
||||
resetMutation()
|
||||
}
|
||||
|
||||
const handleCloseAttempt = () => {
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
'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 type { ChunkData } from '@/lib/knowledge/types'
|
||||
import { useDeleteChunk } from '@/hooks/queries/knowledge'
|
||||
import { knowledgeKeys } from '@/hooks/queries/knowledge'
|
||||
|
||||
const logger = createLogger('DeleteChunkModal')
|
||||
|
||||
interface DeleteChunkModalProps {
|
||||
chunk: ChunkData | null
|
||||
@@ -19,12 +24,44 @@ export function DeleteChunkModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: DeleteChunkModalProps) {
|
||||
const { mutate: deleteChunk, isPending: isDeleting } = useDeleteChunk()
|
||||
const queryClient = useQueryClient()
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
const handleDeleteChunk = () => {
|
||||
const handleDeleteChunk = async () => {
|
||||
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
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
} from '@/hooks/kb/use-knowledge-base-tag-definitions'
|
||||
import { useNextAvailableSlot } from '@/hooks/kb/use-next-available-slot'
|
||||
import { type TagDefinitionInput, useTagDefinitions } from '@/hooks/kb/use-tag-definitions'
|
||||
import { useUpdateDocumentTags } from '@/hooks/queries/knowledge'
|
||||
|
||||
const logger = createLogger('DocumentTagsModal')
|
||||
|
||||
@@ -59,6 +58,8 @@ function formatValueForDisplay(value: string, fieldType: string): string {
|
||||
try {
|
||||
const date = new Date(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))) {
|
||||
return new Date(
|
||||
date.getUTCFullYear(),
|
||||
@@ -95,7 +96,6 @@ export function DocumentTagsModal({
|
||||
const documentTagHook = useTagDefinitions(knowledgeBaseId, documentId)
|
||||
const kbTagHook = useKnowledgeBaseTagDefinitions(knowledgeBaseId)
|
||||
const { getNextAvailableSlot: getServerNextSlot } = useNextAvailableSlot(knowledgeBaseId)
|
||||
const { mutateAsync: updateDocumentTags } = useUpdateDocumentTags()
|
||||
|
||||
const { saveTagDefinitions, tagDefinitions, fetchTagDefinitions } = documentTagHook
|
||||
const { tagDefinitions: kbTagDefinitions, fetchTagDefinitions: refreshTagDefinitions } = kbTagHook
|
||||
@@ -118,6 +118,7 @@ export function DocumentTagsModal({
|
||||
const definition = definitions.find((def) => def.tagSlot === slot)
|
||||
|
||||
if (rawValue !== null && rawValue !== undefined && definition) {
|
||||
// Convert value to string for storage
|
||||
const stringValue = String(rawValue).trim()
|
||||
if (stringValue) {
|
||||
tags.push({
|
||||
@@ -141,34 +142,41 @@ export function DocumentTagsModal({
|
||||
async (tagsToSave: DocumentTag[]) => {
|
||||
if (!documentData) return
|
||||
|
||||
const tagData: Record<string, string> = {}
|
||||
try {
|
||||
const tagData: Record<string, string> = {}
|
||||
|
||||
ALL_TAG_SLOTS.forEach((slot) => {
|
||||
const tag = tagsToSave.find((t) => t.slot === slot)
|
||||
if (tag?.value.trim()) {
|
||||
tagData[slot] = tag.value.trim()
|
||||
} else {
|
||||
tagData[slot] = ''
|
||||
// Only include tags that have values (omit empty ones)
|
||||
// Use empty string for slots that should be cleared
|
||||
ALL_TAG_SLOTS.forEach((slot) => {
|
||||
const tag = tagsToSave.find((t) => t.slot === slot)
|
||||
if (tag?.value.trim()) {
|
||||
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({
|
||||
knowledgeBaseId,
|
||||
documentId,
|
||||
tags: tagData,
|
||||
})
|
||||
|
||||
onDocumentUpdate?.(tagData)
|
||||
await fetchTagDefinitions()
|
||||
onDocumentUpdate?.(tagData as Record<string, string>)
|
||||
await fetchTagDefinitions()
|
||||
} catch (error) {
|
||||
logger.error('Error updating document tags:', error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
[
|
||||
documentData,
|
||||
knowledgeBaseId,
|
||||
documentId,
|
||||
updateDocumentTags,
|
||||
fetchTagDefinitions,
|
||||
onDocumentUpdate,
|
||||
]
|
||||
[documentData, knowledgeBaseId, documentId, fetchTagDefinitions, onDocumentUpdate]
|
||||
)
|
||||
|
||||
const handleRemoveTag = async (index: number) => {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import {
|
||||
Button,
|
||||
@@ -18,7 +19,7 @@ import {
|
||||
import type { ChunkData, DocumentData } from '@/lib/knowledge/types'
|
||||
import { getAccurateTokenCount, getTokenStrings } from '@/lib/tokenization/estimators'
|
||||
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')
|
||||
|
||||
@@ -49,22 +50,17 @@ export function EditChunkModal({
|
||||
onNavigateToPage,
|
||||
maxChunkSize,
|
||||
}: EditChunkModalProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
const {
|
||||
mutate: updateChunk,
|
||||
isPending: isSaving,
|
||||
error: mutationError,
|
||||
reset: resetMutation,
|
||||
} = useUpdateChunk()
|
||||
const [editedContent, setEditedContent] = useState(chunk?.content || '')
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [isNavigating, setIsNavigating] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showUnsavedChangesAlert, setShowUnsavedChangesAlert] = useState(false)
|
||||
const [pendingNavigation, setPendingNavigation] = useState<(() => void) | null>(null)
|
||||
const [tokenizerOn, setTokenizerOn] = useState(false)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const error = mutationError?.message ?? null
|
||||
|
||||
const hasUnsavedChanges = editedContent !== (chunk?.content || '')
|
||||
|
||||
const tokenStrings = useMemo(() => {
|
||||
@@ -106,15 +102,44 @@ export function EditChunkModal({
|
||||
const canNavigatePrev = currentChunkIndex > 0 || currentPage > 1
|
||||
const canNavigateNext = currentChunkIndex < allChunks.length - 1 || currentPage < totalPages
|
||||
|
||||
const handleSaveContent = () => {
|
||||
const handleSaveContent = async () => {
|
||||
if (!chunk || !document) return
|
||||
|
||||
updateChunk({
|
||||
knowledgeBaseId,
|
||||
documentId: document.id,
|
||||
chunkId: chunk.id,
|
||||
content: editedContent,
|
||||
})
|
||||
try {
|
||||
setIsSaving(true)
|
||||
setError(null)
|
||||
|
||||
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') => {
|
||||
@@ -140,6 +165,7 @@ export function EditChunkModal({
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Error navigating ${direction}:`, err)
|
||||
setError(`Failed to navigate to ${direction === 'prev' ? 'previous' : 'next'} chunk`)
|
||||
} finally {
|
||||
setIsNavigating(false)
|
||||
}
|
||||
@@ -159,7 +185,6 @@ export function EditChunkModal({
|
||||
setPendingNavigation(null)
|
||||
setShowUnsavedChangesAlert(true)
|
||||
} else {
|
||||
resetMutation()
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
@@ -170,7 +195,6 @@ export function EditChunkModal({
|
||||
void pendingNavigation()
|
||||
setPendingNavigation(null)
|
||||
} else {
|
||||
resetMutation()
|
||||
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 { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||
import { useDocument, useDocumentChunks, useKnowledgeBase } from '@/hooks/kb/use-knowledge'
|
||||
import {
|
||||
knowledgeKeys,
|
||||
useBulkChunkOperation,
|
||||
useDeleteDocument,
|
||||
useDocumentChunkSearchQuery,
|
||||
useUpdateChunk,
|
||||
} from '@/hooks/queries/knowledge'
|
||||
import { knowledgeKeys, useDocumentChunkSearchQuery } from '@/hooks/queries/knowledge'
|
||||
|
||||
const logger = createLogger('Document')
|
||||
|
||||
@@ -409,13 +403,11 @@ export function Document({
|
||||
const [isCreateChunkModalOpen, setIsCreateChunkModalOpen] = useState(false)
|
||||
const [chunkToDelete, setChunkToDelete] = useState<ChunkData | null>(null)
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false)
|
||||
const [isBulkOperating, setIsBulkOperating] = useState(false)
|
||||
const [showDeleteDocumentDialog, setShowDeleteDocumentDialog] = useState(false)
|
||||
const [isDeletingDocument, setIsDeletingDocument] = useState(false)
|
||||
const [contextMenuChunk, setContextMenuChunk] = useState<ChunkData | null>(null)
|
||||
|
||||
const { mutate: updateChunkMutation } = useUpdateChunk()
|
||||
const { mutate: deleteDocumentMutation, isPending: isDeletingDocument } = useDeleteDocument()
|
||||
const { mutate: bulkChunkMutation, isPending: isBulkOperating } = useBulkChunkOperation()
|
||||
|
||||
const {
|
||||
isOpen: isContextMenuOpen,
|
||||
position: contextMenuPosition,
|
||||
@@ -448,23 +440,36 @@ export function Document({
|
||||
setSelectedChunk(null)
|
||||
}
|
||||
|
||||
const handleToggleEnabled = (chunkId: string) => {
|
||||
const handleToggleEnabled = async (chunkId: string) => {
|
||||
const chunk = displayChunks.find((c) => c.id === chunkId)
|
||||
if (!chunk) return
|
||||
|
||||
updateChunkMutation(
|
||||
{
|
||||
knowledgeBaseId,
|
||||
documentId,
|
||||
chunkId,
|
||||
enabled: !chunk.enabled,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
updateChunk(chunkId, { enabled: !chunk.enabled })
|
||||
},
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/knowledge/${knowledgeBaseId}/documents/${documentId}/chunks/${chunkId}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
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) => {
|
||||
@@ -510,65 +515,107 @@ export function Document({
|
||||
/**
|
||||
* Handles deleting the document
|
||||
*/
|
||||
const handleDeleteDocument = () => {
|
||||
const handleDeleteDocument = async () => {
|
||||
if (!documentData) return
|
||||
|
||||
deleteDocumentMutation(
|
||||
{ knowledgeBaseId, documentId },
|
||||
{
|
||||
onSuccess: () => {
|
||||
router.push(`/workspace/${workspaceId}/knowledge/${knowledgeBaseId}`)
|
||||
},
|
||||
try {
|
||||
setIsDeletingDocument(true)
|
||||
|
||||
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/documents/${documentId}`, {
|
||||
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',
|
||||
chunks: ChunkData[]
|
||||
) => {
|
||||
if (chunks.length === 0) return
|
||||
|
||||
bulkChunkMutation(
|
||||
{
|
||||
knowledgeBaseId,
|
||||
documentId,
|
||||
operation,
|
||||
chunkIds: chunks.map((chunk) => chunk.id),
|
||||
},
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
if (operation === 'delete' || result.errorCount > 0) {
|
||||
refreshChunks()
|
||||
} else {
|
||||
chunks.forEach((chunk) => {
|
||||
updateChunk(chunk.id, { enabled: operation === 'enable' })
|
||||
})
|
||||
}
|
||||
logger.info(`Successfully ${operation}d ${result.successCount} chunks`)
|
||||
setSelectedChunks(new Set())
|
||||
},
|
||||
try {
|
||||
setIsBulkOperating(true)
|
||||
|
||||
const response = await fetch(
|
||||
`/api/knowledge/${knowledgeBaseId}/documents/${documentId}/chunks`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
operation,
|
||||
chunkIds: chunks.map((chunk) => chunk.id),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
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(
|
||||
(chunk) => selectedChunks.has(chunk.id) && !chunk.enabled
|
||||
)
|
||||
performBulkChunkOperation('enable', chunksToEnable)
|
||||
await performBulkChunkOperation('enable', chunksToEnable)
|
||||
}
|
||||
|
||||
const handleBulkDisable = () => {
|
||||
const handleBulkDisable = async () => {
|
||||
const chunksToDisable = displayChunks.filter(
|
||||
(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))
|
||||
performBulkChunkOperation('delete', chunksToDelete)
|
||||
await performBulkChunkOperation('delete', chunksToDelete)
|
||||
}
|
||||
|
||||
const selectedChunksList = displayChunks.filter((chunk) => selectedChunks.has(chunk.id))
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { format } from 'date-fns'
|
||||
import {
|
||||
AlertCircle,
|
||||
@@ -61,12 +62,7 @@ import {
|
||||
type TagDefinition,
|
||||
useKnowledgeBaseTagDefinitions,
|
||||
} from '@/hooks/kb/use-knowledge-base-tag-definitions'
|
||||
import {
|
||||
useBulkDocumentOperation,
|
||||
useDeleteDocument,
|
||||
useDeleteKnowledgeBase,
|
||||
useUpdateDocument,
|
||||
} from '@/hooks/queries/knowledge'
|
||||
import { knowledgeKeys } from '@/hooks/queries/knowledge'
|
||||
|
||||
const logger = createLogger('KnowledgeBase')
|
||||
|
||||
@@ -411,17 +407,12 @@ export function KnowledgeBase({
|
||||
id,
|
||||
knowledgeBaseName: passedKnowledgeBaseName,
|
||||
}: KnowledgeBaseProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
const { removeKnowledgeBase } = useKnowledgeBasesList(workspaceId, { enabled: false })
|
||||
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 [showTagsModal, setShowTagsModal] = useState(false)
|
||||
|
||||
@@ -436,6 +427,8 @@ export function KnowledgeBase({
|
||||
const [selectedDocuments, setSelectedDocuments] = useState<Set<string>>(new Set())
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [showAddDocumentsModal, setShowAddDocumentsModal] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [isBulkOperating, setIsBulkOperating] = useState(false)
|
||||
const [showDeleteDocumentModal, setShowDeleteDocumentModal] = useState(false)
|
||||
const [documentToDelete, setDocumentToDelete] = useState<string | null>(null)
|
||||
const [showBulkDeleteModal, setShowBulkDeleteModal] = useState(false)
|
||||
@@ -557,7 +550,7 @@ export function KnowledgeBase({
|
||||
/**
|
||||
* Checks for documents with stale processing states and marks them as failed
|
||||
*/
|
||||
const checkForDeadProcesses = () => {
|
||||
const checkForDeadProcesses = async () => {
|
||||
const now = new Date()
|
||||
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`)
|
||||
|
||||
staleDocuments.forEach((doc) => {
|
||||
updateDocumentMutation(
|
||||
{
|
||||
knowledgeBaseId: id,
|
||||
documentId: doc.id,
|
||||
updates: { markFailedDueToTimeout: true },
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
logger.info(`Successfully marked dead process as failed for document: ${doc.filename}`)
|
||||
const markFailedPromises = staleDocuments.map(async (doc) => {
|
||||
try {
|
||||
const response = await fetch(`/api/knowledge/${id}/documents/${doc.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
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)
|
||||
if (!document) return
|
||||
|
||||
const newEnabled = !document.enabled
|
||||
|
||||
// Optimistic update
|
||||
updateDocument(docId, { enabled: newEnabled })
|
||||
|
||||
updateDocumentMutation(
|
||||
{
|
||||
knowledgeBaseId: id,
|
||||
documentId: docId,
|
||||
updates: { enabled: newEnabled },
|
||||
},
|
||||
{
|
||||
onError: () => {
|
||||
// Rollback on error
|
||||
updateDocument(docId, { enabled: !newEnabled })
|
||||
try {
|
||||
const response = await fetch(`/api/knowledge/${id}/documents/${docId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
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
|
||||
*/
|
||||
const handleRetryDocument = (docId: string) => {
|
||||
// Optimistic update
|
||||
updateDocument(docId, {
|
||||
processingStatus: 'pending',
|
||||
processingError: null,
|
||||
processingStartedAt: null,
|
||||
processingCompletedAt: null,
|
||||
})
|
||||
const handleRetryDocument = async (docId: string) => {
|
||||
try {
|
||||
updateDocument(docId, {
|
||||
processingStatus: 'pending',
|
||||
processingError: null,
|
||||
processingStartedAt: null,
|
||||
processingCompletedAt: null,
|
||||
})
|
||||
|
||||
updateDocumentMutation(
|
||||
{
|
||||
knowledgeBaseId: id,
|
||||
documentId: docId,
|
||||
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',
|
||||
})
|
||||
const response = await fetch(`/api/knowledge/${id}/documents/${docId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
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 previousName = currentDoc?.filename
|
||||
|
||||
// Optimistic update
|
||||
updateDocument(documentId, { filename: newName })
|
||||
queryClient.setQueryData<DocumentData>(knowledgeKeys.document(id, documentId), (previous) =>
|
||||
previous ? { ...previous, filename: newName } : previous
|
||||
)
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
updateDocumentMutation(
|
||||
{
|
||||
knowledgeBaseId: id,
|
||||
documentId,
|
||||
updates: { filename: newName },
|
||||
try {
|
||||
const response = await fetch(`/api/knowledge/${id}/documents/${documentId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
logger.info(`Document renamed: ${documentId}`)
|
||||
resolve()
|
||||
},
|
||||
onError: (err) => {
|
||||
// Rollback on error
|
||||
if (previousName !== undefined) {
|
||||
updateDocument(documentId, { filename: previousName })
|
||||
}
|
||||
logger.error('Error renaming document:', err)
|
||||
reject(err)
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
body: JSON.stringify({ filename: newName }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const result = await response.json()
|
||||
throw new Error(result.error || 'Failed to rename document')
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!result.success) {
|
||||
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
|
||||
*/
|
||||
const confirmDeleteDocument = () => {
|
||||
const confirmDeleteDocument = async () => {
|
||||
if (!documentToDelete) return
|
||||
|
||||
deleteDocumentMutation(
|
||||
{ knowledgeBaseId: id, documentId: documentToDelete },
|
||||
{
|
||||
onSuccess: () => {
|
||||
refreshDocuments()
|
||||
setSelectedDocuments((prev) => {
|
||||
const newSet = new Set(prev)
|
||||
newSet.delete(documentToDelete)
|
||||
return newSet
|
||||
})
|
||||
},
|
||||
onSettled: () => {
|
||||
setShowDeleteDocumentModal(false)
|
||||
setDocumentToDelete(null)
|
||||
},
|
||||
try {
|
||||
const response = await fetch(`/api/knowledge/${id}/documents/${documentToDelete}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete document')
|
||||
}
|
||||
)
|
||||
|
||||
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
|
||||
*/
|
||||
const handleDeleteKnowledgeBase = () => {
|
||||
const handleDeleteKnowledgeBase = async () => {
|
||||
if (!knowledgeBase) return
|
||||
|
||||
deleteKnowledgeBaseMutation(
|
||||
{ knowledgeBaseId: id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
removeKnowledgeBase(id)
|
||||
router.push(`/workspace/${workspaceId}/knowledge`)
|
||||
},
|
||||
try {
|
||||
setIsDeleting(true)
|
||||
|
||||
const response = await fetch(`/api/knowledge/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
|
||||
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
|
||||
*/
|
||||
const handleBulkEnable = () => {
|
||||
const handleBulkEnable = async () => {
|
||||
const documentsToEnable = documents.filter(
|
||||
(doc) => selectedDocuments.has(doc.id) && !doc.enabled
|
||||
)
|
||||
|
||||
if (documentsToEnable.length === 0) return
|
||||
|
||||
bulkDocumentMutation(
|
||||
{
|
||||
knowledgeBaseId: id,
|
||||
operation: 'enable',
|
||||
documentIds: documentsToEnable.map((doc) => doc.id),
|
||||
},
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
result.updatedDocuments?.forEach((updatedDoc) => {
|
||||
updateDocument(updatedDoc.id, { enabled: updatedDoc.enabled })
|
||||
})
|
||||
logger.info(`Successfully enabled ${result.successCount} documents`)
|
||||
setSelectedDocuments(new Set())
|
||||
try {
|
||||
setIsBulkOperating(true)
|
||||
|
||||
const response = await fetch(`/api/knowledge/${id}/documents`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
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
|
||||
*/
|
||||
const handleBulkDisable = () => {
|
||||
const handleBulkDisable = async () => {
|
||||
const documentsToDisable = documents.filter(
|
||||
(doc) => selectedDocuments.has(doc.id) && doc.enabled
|
||||
)
|
||||
|
||||
if (documentsToDisable.length === 0) return
|
||||
|
||||
bulkDocumentMutation(
|
||||
{
|
||||
knowledgeBaseId: id,
|
||||
operation: 'disable',
|
||||
documentIds: documentsToDisable.map((doc) => doc.id),
|
||||
},
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
result.updatedDocuments?.forEach((updatedDoc) => {
|
||||
updateDocument(updatedDoc.id, { enabled: updatedDoc.enabled })
|
||||
})
|
||||
logger.info(`Successfully disabled ${result.successCount} documents`)
|
||||
setSelectedDocuments(new Set())
|
||||
try {
|
||||
setIsBulkOperating(true)
|
||||
|
||||
const response = await fetch(`/api/knowledge/${id}/documents`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
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
|
||||
*/
|
||||
const confirmBulkDelete = () => {
|
||||
const confirmBulkDelete = async () => {
|
||||
const documentsToDelete = documents.filter((doc) => selectedDocuments.has(doc.id))
|
||||
|
||||
if (documentsToDelete.length === 0) return
|
||||
|
||||
bulkDocumentMutation(
|
||||
{
|
||||
knowledgeBaseId: id,
|
||||
operation: 'delete',
|
||||
documentIds: documentsToDelete.map((doc) => doc.id),
|
||||
},
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
logger.info(`Successfully deleted ${result.successCount} documents`)
|
||||
refreshDocuments()
|
||||
setSelectedDocuments(new Set())
|
||||
},
|
||||
onSettled: () => {
|
||||
setShowBulkDeleteModal(false)
|
||||
try {
|
||||
setIsBulkOperating(true)
|
||||
|
||||
const response = await fetch(`/api/knowledge/${id}/documents`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
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))
|
||||
|
||||
@@ -22,10 +22,10 @@ import {
|
||||
type TagDefinition,
|
||||
useKnowledgeBaseTagDefinitions,
|
||||
} from '@/hooks/kb/use-knowledge-base-tag-definitions'
|
||||
import { useCreateTagDefinition, useDeleteTagDefinition } from '@/hooks/queries/knowledge'
|
||||
|
||||
const logger = createLogger('BaseTagsModal')
|
||||
|
||||
/** Field type display labels */
|
||||
const FIELD_TYPE_LABELS: Record<string, string> = {
|
||||
text: 'Text',
|
||||
number: 'Number',
|
||||
@@ -45,6 +45,7 @@ interface DocumentListProps {
|
||||
totalCount: number
|
||||
}
|
||||
|
||||
/** Displays a list of documents affected by tag operations */
|
||||
function DocumentList({ documents, totalCount }: DocumentListProps) {
|
||||
const displayLimit = 5
|
||||
const hasMore = totalCount > displayLimit
|
||||
@@ -94,14 +95,13 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
const { tagDefinitions: kbTagDefinitions, fetchTagDefinitions: refreshTagDefinitions } =
|
||||
useKnowledgeBaseTagDefinitions(knowledgeBaseId)
|
||||
|
||||
const createTagMutation = useCreateTagDefinition()
|
||||
const deleteTagMutation = useDeleteTagDefinition()
|
||||
|
||||
const [deleteTagDialogOpen, setDeleteTagDialogOpen] = useState(false)
|
||||
const [selectedTag, setSelectedTag] = useState<TagDefinition | null>(null)
|
||||
const [viewDocumentsDialogOpen, setViewDocumentsDialogOpen] = useState(false)
|
||||
const [isDeletingTag, setIsDeletingTag] = useState(false)
|
||||
const [tagUsageData, setTagUsageData] = useState<TagUsageData[]>([])
|
||||
const [isCreatingTag, setIsCreatingTag] = useState(false)
|
||||
const [isSavingTag, setIsSavingTag] = useState(false)
|
||||
const [createTagForm, setCreateTagForm] = useState({
|
||||
displayName: '',
|
||||
fieldType: 'text',
|
||||
@@ -177,12 +177,13 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
}
|
||||
|
||||
const tagNameConflict =
|
||||
isCreatingTag && !createTagMutation.isPending && hasTagNameConflict(createTagForm.displayName)
|
||||
isCreatingTag && !isSavingTag && hasTagNameConflict(createTagForm.displayName)
|
||||
|
||||
const canSaveTag = () => {
|
||||
return createTagForm.displayName.trim() && !hasTagNameConflict(createTagForm.displayName)
|
||||
}
|
||||
|
||||
/** Get slot usage counts per field type */
|
||||
const getSlotUsageByFieldType = (fieldType: string): { used: number; max: number } => {
|
||||
const config = TAG_SLOT_CONFIG[fieldType as keyof typeof TAG_SLOT_CONFIG]
|
||||
if (!config) return { used: 0, max: 0 }
|
||||
@@ -190,11 +191,13 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
return { used, max: config.maxSlots }
|
||||
}
|
||||
|
||||
/** Check if a field type has available slots */
|
||||
const hasAvailableSlots = (fieldType: string): boolean => {
|
||||
const { used, max } = getSlotUsageByFieldType(fieldType)
|
||||
return used < max
|
||||
}
|
||||
|
||||
/** Field type options for Combobox */
|
||||
const fieldTypeOptions: ComboboxOption[] = useMemo(() => {
|
||||
return SUPPORTED_FIELD_TYPES.filter((type) => hasAvailableSlots(type)).map((type) => {
|
||||
const { used, max } = getSlotUsageByFieldType(type)
|
||||
@@ -208,17 +211,43 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
const saveTagDefinition = async () => {
|
||||
if (!canSaveTag()) return
|
||||
|
||||
setIsSavingTag(true)
|
||||
try {
|
||||
// Check if selected field type has available slots
|
||||
if (!hasAvailableSlots(createTagForm.fieldType)) {
|
||||
throw new Error(`No available slots for ${createTagForm.fieldType} type`)
|
||||
}
|
||||
|
||||
await createTagMutation.mutateAsync({
|
||||
knowledgeBaseId,
|
||||
// Get the next available slot from the API
|
||||
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(),
|
||||
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()])
|
||||
|
||||
setCreateTagForm({
|
||||
@@ -228,17 +257,27 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
setIsCreatingTag(false)
|
||||
} catch (error) {
|
||||
logger.error('Error creating tag definition:', error)
|
||||
} finally {
|
||||
setIsSavingTag(false)
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDeleteTag = async () => {
|
||||
if (!selectedTag) return
|
||||
|
||||
setIsDeletingTag(true)
|
||||
try {
|
||||
await deleteTagMutation.mutateAsync({
|
||||
knowledgeBaseId,
|
||||
tagDefinitionId: selectedTag.id,
|
||||
})
|
||||
const response = await fetch(
|
||||
`/api/knowledge/${knowledgeBaseId}/tag-definitions/${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()])
|
||||
|
||||
@@ -246,6 +285,8 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
setSelectedTag(null)
|
||||
} catch (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'
|
||||
disabled={
|
||||
!canSaveTag() ||
|
||||
createTagMutation.isPending ||
|
||||
isSavingTag ||
|
||||
!hasAvailableSlots(createTagForm.fieldType)
|
||||
}
|
||||
>
|
||||
{createTagMutation.isPending ? 'Creating...' : 'Create Tag'}
|
||||
{isSavingTag ? 'Creating...' : 'Create Tag'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -440,17 +481,13 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
<ModalFooter>
|
||||
<Button
|
||||
variant='default'
|
||||
disabled={deleteTagMutation.isPending}
|
||||
disabled={isDeletingTag}
|
||||
onClick={() => setDeleteTagDialogOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='destructive'
|
||||
onClick={confirmDeleteTag}
|
||||
disabled={deleteTagMutation.isPending}
|
||||
>
|
||||
{deleteTagMutation.isPending ? 'Deleting...' : 'Delete Tag'}
|
||||
<Button variant='destructive' onClick={confirmDeleteTag} disabled={isDeletingTag}>
|
||||
{isDeletingTag ? <>Deleting...</> : 'Delete Tag'}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
@@ -462,7 +499,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
<ModalHeader>Documents using "{selectedTag?.displayName}"</ModalHeader>
|
||||
<ModalBody>
|
||||
<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 !== 1 ? 's are' : ' is'} currently using this tag
|
||||
definition.
|
||||
@@ -470,7 +507,7 @@ export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsM
|
||||
|
||||
{selectedTagUsage?.documentCount === 0 ? (
|
||||
<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
|
||||
to free up the tag slot.
|
||||
</p>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { Loader2, RotateCcw, X } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
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 { ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
|
||||
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')
|
||||
|
||||
@@ -81,11 +82,10 @@ interface SubmitStatus {
|
||||
export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
const createKnowledgeBaseMutation = useCreateKnowledgeBase(workspaceId)
|
||||
const deleteKnowledgeBaseMutation = useDeleteKnowledgeBase(workspaceId)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [submitStatus, setSubmitStatus] = useState<SubmitStatus | null>(null)
|
||||
const [files, setFiles] = useState<FileWithPreview[]>([])
|
||||
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) => {
|
||||
setIsSubmitting(true)
|
||||
setSubmitStatus(null)
|
||||
|
||||
try {
|
||||
const newKnowledgeBase = await createKnowledgeBaseMutation.mutateAsync({
|
||||
const knowledgeBasePayload = {
|
||||
name: data.name,
|
||||
description: data.description || undefined,
|
||||
workspaceId: workspaceId,
|
||||
@@ -261,8 +259,29 @@ export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
|
||||
minSize: data.minChunkSize,
|
||||
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) {
|
||||
try {
|
||||
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(`Started processing ${uploadedFiles.length} documents in the background`)
|
||||
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: knowledgeKeys.list(workspaceId),
|
||||
})
|
||||
} catch (uploadError) {
|
||||
logger.error('File upload failed, deleting knowledge base:', uploadError)
|
||||
try {
|
||||
await deleteKnowledgeBaseMutation.mutateAsync({
|
||||
knowledgeBaseId: newKnowledgeBase.id,
|
||||
await fetch(`/api/knowledge/${newKnowledgeBase.id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
logger.info(`Deleted orphaned knowledge base: ${newKnowledgeBase.id}`)
|
||||
} catch (deleteError) {
|
||||
@@ -286,6 +309,10 @@ export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
|
||||
}
|
||||
throw uploadError
|
||||
}
|
||||
} else {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: knowledgeKeys.list(workspaceId),
|
||||
})
|
||||
}
|
||||
|
||||
files.forEach((file) => URL.revokeObjectURL(file.preview))
|
||||
@@ -298,6 +325,8 @@ export function CreateBaseModal({ open, onOpenChange }: CreateBaseModalProps) {
|
||||
type: 'error',
|
||||
message: error instanceof Error ? error.message : 'An unknown error occurred',
|
||||
})
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { AlertTriangle, ChevronDown, LibraryBig, MoreHorizontal } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
@@ -14,7 +15,7 @@ import {
|
||||
} from '@/components/emcn'
|
||||
import { Trash } from '@/components/emcn/icons/trash'
|
||||
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')
|
||||
|
||||
@@ -53,13 +54,14 @@ interface Workspace {
|
||||
}
|
||||
|
||||
export function KnowledgeHeader({ breadcrumbs, options }: KnowledgeHeaderProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const [isActionsPopoverOpen, setIsActionsPopoverOpen] = useState(false)
|
||||
const [isWorkspacePopoverOpen, setIsWorkspacePopoverOpen] = useState(false)
|
||||
const [workspaces, setWorkspaces] = useState<Workspace[]>([])
|
||||
const [isLoadingWorkspaces, setIsLoadingWorkspaces] = useState(false)
|
||||
const [isUpdatingWorkspace, setIsUpdatingWorkspace] = useState(false)
|
||||
|
||||
const updateKnowledgeBase = useUpdateKnowledgeBase()
|
||||
|
||||
// Fetch available workspaces
|
||||
useEffect(() => {
|
||||
if (!options?.knowledgeBaseId) return
|
||||
|
||||
@@ -74,6 +76,7 @@ export function KnowledgeHeader({ breadcrumbs, options }: KnowledgeHeaderProps)
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Filter workspaces where user has write/admin permissions
|
||||
const availableWorkspaces = data.workspaces
|
||||
.filter((ws: any) => ws.permissions === 'write' || ws.permissions === 'admin')
|
||||
.map((ws: any) => ({
|
||||
@@ -94,27 +97,47 @@ export function KnowledgeHeader({ breadcrumbs, options }: KnowledgeHeaderProps)
|
||||
}, [options?.knowledgeBaseId])
|
||||
|
||||
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(
|
||||
{
|
||||
knowledgeBaseId: options.knowledgeBaseId,
|
||||
updates: { workspaceId },
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
logger.info(
|
||||
`Knowledge base workspace updated: ${options.knowledgeBaseId} -> ${workspaceId}`
|
||||
)
|
||||
options.onWorkspaceChange?.(workspaceId)
|
||||
},
|
||||
onError: (err) => {
|
||||
logger.error('Error updating workspace:', err)
|
||||
const response = await fetch(`/api/knowledge/${options.knowledgeBaseId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
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)
|
||||
@@ -124,6 +147,7 @@ export function KnowledgeHeader({ breadcrumbs, options }: KnowledgeHeaderProps)
|
||||
<div className={HEADER_STYLES.container}>
|
||||
<div className={HEADER_STYLES.breadcrumbs}>
|
||||
{breadcrumbs.map((breadcrumb, index) => {
|
||||
// Use unique identifier when available, fallback to content-based key
|
||||
const key = breadcrumb.id || `${breadcrumb.label}-${breadcrumb.href || index}`
|
||||
|
||||
return (
|
||||
@@ -165,13 +189,13 @@ export function KnowledgeHeader({ breadcrumbs, options }: KnowledgeHeaderProps)
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
disabled={isLoadingWorkspaces || updateKnowledgeBase.isPending}
|
||||
disabled={isLoadingWorkspaces || isUpdatingWorkspace}
|
||||
className={filterButtonClass}
|
||||
>
|
||||
<span className='truncate'>
|
||||
{isLoadingWorkspaces
|
||||
? 'Loading...'
|
||||
: updateKnowledgeBase.isPending
|
||||
: isUpdatingWorkspace
|
||||
? 'Updating...'
|
||||
: currentWorkspace?.name || 'No workspace'}
|
||||
</span>
|
||||
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||
import { useContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks'
|
||||
import { useKnowledgeBasesList } from '@/hooks/kb/use-knowledge'
|
||||
import { useDeleteKnowledgeBase, useUpdateKnowledgeBase } from '@/hooks/queries/knowledge'
|
||||
import { useDebounce } from '@/hooks/use-debounce'
|
||||
|
||||
const logger = createLogger('Knowledge')
|
||||
@@ -52,12 +51,10 @@ export function Knowledge() {
|
||||
const params = useParams()
|
||||
const workspaceId = params.workspaceId as string
|
||||
|
||||
const { knowledgeBases, isLoading, error } = useKnowledgeBasesList(workspaceId)
|
||||
const { knowledgeBases, isLoading, error, removeKnowledgeBase, updateKnowledgeBase } =
|
||||
useKnowledgeBasesList(workspaceId)
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
|
||||
const { mutateAsync: updateKnowledgeBaseMutation } = useUpdateKnowledgeBase(workspaceId)
|
||||
const { mutateAsync: deleteKnowledgeBaseMutation } = useDeleteKnowledgeBase(workspaceId)
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 300)
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
|
||||
@@ -115,13 +112,29 @@ export function Knowledge() {
|
||||
*/
|
||||
const handleUpdateKnowledgeBase = useCallback(
|
||||
async (id: string, name: string, description: string) => {
|
||||
await updateKnowledgeBaseMutation({
|
||||
knowledgeBaseId: id,
|
||||
updates: { name, description },
|
||||
const response = await fetch(`/api/knowledge/${id}`, {
|
||||
method: 'PUT',
|
||||
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(
|
||||
async (id: string) => {
|
||||
await deleteKnowledgeBaseMutation({ knowledgeBaseId: id })
|
||||
logger.info(`Knowledge base deleted: ${id}`)
|
||||
const response = await fetch(`/api/knowledge/${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 { CopilotMarkdownRenderer } from './markdown-renderer'
|
||||
export { default as CopilotMarkdownRenderer } from './markdown-renderer'
|
||||
export * from './smooth-streaming'
|
||||
export * from './thinking-block'
|
||||
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 { 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
|
||||
|
||||
/** Props for the StreamingIndicator component */
|
||||
/**
|
||||
* Props for the StreamingIndicator component
|
||||
*/
|
||||
interface StreamingIndicatorProps {
|
||||
/** Optional class name for layout adjustments */
|
||||
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) => (
|
||||
<div className={cn('flex h-[1.25rem] items-center text-muted-foreground', className)}>
|
||||
<div className='flex space-x-0.5'>
|
||||
@@ -24,7 +34,9 @@ export const StreamingIndicator = memo(({ className }: StreamingIndicatorProps)
|
||||
|
||||
StreamingIndicator.displayName = 'StreamingIndicator'
|
||||
|
||||
/** Props for the SmoothStreamingText component */
|
||||
/**
|
||||
* Props for the SmoothStreamingText component
|
||||
*/
|
||||
interface SmoothStreamingTextProps {
|
||||
/** Content to display with streaming animation */
|
||||
content: string
|
||||
@@ -32,12 +44,20 @@ interface SmoothStreamingTextProps {
|
||||
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(
|
||||
({ content, isStreaming }: SmoothStreamingTextProps) => {
|
||||
// Initialize with full content when not streaming to avoid flash on page load
|
||||
const [displayedContent, setDisplayedContent] = useState(() => (isStreaming ? '' : content))
|
||||
const contentRef = useRef(content)
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
// Initialize index based on streaming state
|
||||
const indexRef = useRef(isStreaming ? 0 : content.length)
|
||||
const isAnimatingRef = useRef(false)
|
||||
|
||||
@@ -75,6 +95,7 @@ export const SmoothStreamingText = memo(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Streaming ended - show full content immediately
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
@@ -98,6 +119,7 @@ export const SmoothStreamingText = memo(
|
||||
)
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
// Prevent re-renders during streaming unless content actually changed
|
||||
return (
|
||||
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 clsx from 'clsx'
|
||||
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 {
|
||||
return text
|
||||
.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()
|
||||
}
|
||||
|
||||
/** 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
|
||||
|
||||
/** Timer update interval in milliseconds */
|
||||
/**
|
||||
* Timer update interval in milliseconds
|
||||
*/
|
||||
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_CHARS_PER_FRAME = 3
|
||||
|
||||
/** Props for the SmoothThinkingText component */
|
||||
/**
|
||||
* Props for the SmoothThinkingText component
|
||||
*/
|
||||
interface SmoothThinkingTextProps {
|
||||
content: string
|
||||
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(
|
||||
({ content, isStreaming }: SmoothThinkingTextProps) => {
|
||||
// Initialize with full content when not streaming to avoid flash on page load
|
||||
const [displayedContent, setDisplayedContent] = useState(() => (isStreaming ? '' : content))
|
||||
const [showGradient, setShowGradient] = useState(false)
|
||||
const contentRef = useRef(content)
|
||||
const textRef = useRef<HTMLDivElement>(null)
|
||||
const rafRef = useRef<number | null>(null)
|
||||
// Initialize index based on streaming state
|
||||
const indexRef = useRef(isStreaming ? 0 : content.length)
|
||||
const lastFrameTimeRef = useRef<number>(0)
|
||||
const isAnimatingRef = useRef(false)
|
||||
@@ -67,6 +88,7 @@ const SmoothThinkingText = memo(
|
||||
|
||||
if (elapsed >= THINKING_DELAY) {
|
||||
if (currentIndex < currentContent.length) {
|
||||
// Reveal multiple characters per frame for faster streaming
|
||||
const newIndex = Math.min(
|
||||
currentIndex + THINKING_CHARS_PER_FRAME,
|
||||
currentContent.length
|
||||
@@ -88,6 +110,7 @@ const SmoothThinkingText = memo(
|
||||
rafRef.current = requestAnimationFrame(animateText)
|
||||
}
|
||||
} else {
|
||||
// Streaming ended - show full content immediately
|
||||
if (rafRef.current) {
|
||||
cancelAnimationFrame(rafRef.current)
|
||||
}
|
||||
@@ -104,10 +127,30 @@ const SmoothThinkingText = memo(
|
||||
}
|
||||
}, [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 (
|
||||
<div
|
||||
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)]'
|
||||
style={gradientStyle}
|
||||
>
|
||||
<CopilotMarkdownRenderer content={displayedContent} />
|
||||
</div>
|
||||
@@ -122,7 +165,9 @@ const SmoothThinkingText = memo(
|
||||
|
||||
SmoothThinkingText.displayName = 'SmoothThinkingText'
|
||||
|
||||
/** Props for the ThinkingBlock component */
|
||||
/**
|
||||
* Props for the ThinkingBlock component
|
||||
*/
|
||||
interface ThinkingBlockProps {
|
||||
/** Content of the thinking block */
|
||||
content: string
|
||||
@@ -137,8 +182,13 @@ interface ThinkingBlockProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays AI reasoning/thinking process with collapsible content and duration timer.
|
||||
* Auto-expands during streaming and collapses when complete.
|
||||
* ThinkingBlock component displays AI reasoning/thinking process
|
||||
* 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({
|
||||
content,
|
||||
@@ -147,6 +197,7 @@ export function ThinkingBlock({
|
||||
label = 'Thought',
|
||||
hasSpecialTags = false,
|
||||
}: ThinkingBlockProps) {
|
||||
// Strip thinking tags from content on render to handle persisted messages
|
||||
const cleanContent = useMemo(() => stripThinkingTags(content || ''), [content])
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
@@ -158,8 +209,12 @@ export function ThinkingBlock({
|
||||
const lastScrollTopRef = useRef(0)
|
||||
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(() => {
|
||||
// Collapse if streaming ended, there's following content, or special tags arrived
|
||||
if (!isStreaming || hasFollowingContent || hasSpecialTags) {
|
||||
setIsExpanded(false)
|
||||
userCollapsedRef.current = false
|
||||
@@ -172,6 +227,7 @@ export function ThinkingBlock({
|
||||
}
|
||||
}, [isStreaming, cleanContent, hasFollowingContent, hasSpecialTags])
|
||||
|
||||
// Reset start time when streaming begins
|
||||
useEffect(() => {
|
||||
if (isStreaming && !hasFollowingContent) {
|
||||
startTimeRef.current = Date.now()
|
||||
@@ -180,7 +236,9 @@ export function ThinkingBlock({
|
||||
}
|
||||
}, [isStreaming, hasFollowingContent])
|
||||
|
||||
// Update duration timer during streaming (stop when following content arrives)
|
||||
useEffect(() => {
|
||||
// Stop timer if not streaming or if there's following content (thinking is done)
|
||||
if (!isStreaming || hasFollowingContent) return
|
||||
|
||||
const interval = setInterval(() => {
|
||||
@@ -190,6 +248,7 @@ export function ThinkingBlock({
|
||||
return () => clearInterval(interval)
|
||||
}, [isStreaming, hasFollowingContent])
|
||||
|
||||
// Handle scroll events to detect user scrolling away
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current
|
||||
if (!container || !isExpanded) return
|
||||
@@ -208,6 +267,7 @@ export function ThinkingBlock({
|
||||
setUserHasScrolledAway(true)
|
||||
}
|
||||
|
||||
// Re-stick if user scrolls back to bottom with intent
|
||||
if (userHasScrolledAway && isNearBottom && delta > 10) {
|
||||
setUserHasScrolledAway(false)
|
||||
}
|
||||
@@ -221,6 +281,7 @@ export function ThinkingBlock({
|
||||
return () => container.removeEventListener('scroll', handleScroll)
|
||||
}, [isExpanded, userHasScrolledAway])
|
||||
|
||||
// Smart auto-scroll: always scroll to bottom while streaming unless user scrolled away
|
||||
useEffect(() => {
|
||||
if (!isStreaming || !isExpanded || userHasScrolledAway) return
|
||||
|
||||
@@ -241,16 +302,20 @@ export function ThinkingBlock({
|
||||
return () => window.clearInterval(intervalId)
|
||||
}, [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 seconds = Math.max(1, Math.round(ms / 1000))
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
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 durationText = `${label} for ${formatDuration(duration)}`
|
||||
|
||||
// Convert past tense label to present tense for streaming (e.g., "Thought" → "Thinking")
|
||||
const getStreamingLabel = (lbl: string) => {
|
||||
if (lbl === 'Thought') return 'Thinking'
|
||||
if (lbl.endsWith('ed')) return `${lbl.slice(0, -2)}ing`
|
||||
@@ -258,9 +323,11 @@ export function ThinkingBlock({
|
||||
}
|
||||
const streamingLabel = getStreamingLabel(label)
|
||||
|
||||
// During streaming: show header with shimmer effect + expanded content
|
||||
if (!isThinkingDone) {
|
||||
return (
|
||||
<div>
|
||||
{/* Define shimmer keyframes */}
|
||||
<style>{`
|
||||
@keyframes thinking-shimmer {
|
||||
0% { background-position: 150% 0; }
|
||||
@@ -329,6 +396,7 @@ export function ThinkingBlock({
|
||||
)
|
||||
}
|
||||
|
||||
// After done: show collapsible header with duration
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
@@ -358,6 +426,7 @@ export function ThinkingBlock({
|
||||
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)]'>
|
||||
<CopilotMarkdownRenderer content={cleanContent} />
|
||||
</div>
|
||||
@@ -1 +0,0 @@
|
||||
export * from './thinking-block'
|
||||
@@ -1 +0,0 @@
|
||||
export * from './usage-limit-actions'
|
||||
@@ -9,20 +9,18 @@ import {
|
||||
ToolCall,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components'
|
||||
import {
|
||||
CheckpointConfirmation,
|
||||
FileAttachmentDisplay,
|
||||
SmoothStreamingText,
|
||||
StreamingIndicator,
|
||||
ThinkingBlock,
|
||||
UsageLimitActions,
|
||||
} 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 {
|
||||
useCheckpointManagement,
|
||||
useMessageEditing,
|
||||
} 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 { buildMentionHighlightNodes } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
|
||||
import type { CopilotMessage as CopilotMessageType } from '@/stores/panel'
|
||||
import { useCopilotStore } from '@/stores/panel'
|
||||
|
||||
@@ -70,6 +68,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
const isUser = message.role === 'user'
|
||||
const isAssistant = message.role === 'assistant'
|
||||
|
||||
// Store state
|
||||
const {
|
||||
messageCheckpoints: allMessageCheckpoints,
|
||||
messages,
|
||||
@@ -80,18 +79,23 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
isAborting,
|
||||
} = useCopilotStore()
|
||||
|
||||
// Get checkpoints for this message if it's a user message
|
||||
const messageCheckpoints = isUser ? allMessageCheckpoints[message.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(() => {
|
||||
if (!isUser) return false
|
||||
const userMessages = messages.filter((m) => m.role === 'user')
|
||||
return userMessages.length > 0 && userMessages[userMessages.length - 1]?.id === message.id
|
||||
}, [isUser, messages, message.id])
|
||||
|
||||
// UI state
|
||||
const [isHoveringMessage, setIsHoveringMessage] = useState(false)
|
||||
|
||||
const cancelEditRef = useRef<(() => void) | null>(null)
|
||||
|
||||
// Checkpoint management hook
|
||||
const {
|
||||
showRestoreConfirmation,
|
||||
showCheckpointDiscardModal,
|
||||
@@ -114,6 +118,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
() => cancelEditRef.current?.()
|
||||
)
|
||||
|
||||
// Message editing hook
|
||||
const {
|
||||
isEditMode,
|
||||
isExpanded,
|
||||
@@ -142,20 +147,27 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
|
||||
cancelEditRef.current = handleCancelEdit
|
||||
|
||||
// Get clean text content with double newline parsing
|
||||
const cleanTextContent = useMemo(() => {
|
||||
if (!message.content) return ''
|
||||
|
||||
// Parse out excessive newlines (more than 2 consecutive newlines)
|
||||
return message.content.replace(/\n{3,}/g, '\n\n')
|
||||
}, [message.content])
|
||||
|
||||
// Parse special tags from message content (options, plan)
|
||||
// Parse during streaming to show options/plan as they stream in
|
||||
const parsedTags = useMemo(() => {
|
||||
if (isUser) return null
|
||||
|
||||
// Try message.content first
|
||||
if (message.content) {
|
||||
const parsed = parseSpecialTags(message.content)
|
||||
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) {
|
||||
if (block.type === 'text' && block.content) {
|
||||
const parsed = parseSpecialTags(block.content)
|
||||
@@ -164,42 +176,23 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}, [message.content, message.contentBlocks, isUser])
|
||||
|
||||
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])
|
||||
return message.content ? parseSpecialTags(message.content) : null
|
||||
}, [message.content, message.contentBlocks, isUser, isStreaming])
|
||||
|
||||
// Get sendMessage from store for continuation actions
|
||||
const sendMessage = useCopilotStore((s) => s.sendMessage)
|
||||
|
||||
// Handler for option selection
|
||||
const handleOptionSelect = useCallback(
|
||||
(_optionKey: string, optionText: string) => {
|
||||
// Send the option text as a message
|
||||
sendMessage(optionText)
|
||||
},
|
||||
[sendMessage]
|
||||
)
|
||||
|
||||
const isActivelyStreaming = isLastMessage && isStreaming
|
||||
|
||||
// Memoize content blocks to avoid re-rendering unchanged blocks
|
||||
// No entrance animations to prevent layout shift
|
||||
const memoizedContentBlocks = useMemo(() => {
|
||||
if (!message.contentBlocks || message.contentBlocks.length === 0) {
|
||||
return null
|
||||
@@ -209,21 +202,21 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
if (block.type === 'text') {
|
||||
const isLastTextBlock =
|
||||
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 cleanBlockContent = parsed.cleanContent.replace(/\n{3,}/g, '\n\n')
|
||||
|
||||
// Skip if no content after stripping tags
|
||||
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}`
|
||||
|
||||
return (
|
||||
<div key={blockKey} className='w-full max-w-full'>
|
||||
{shouldUseSmoothing ? (
|
||||
<SmoothStreamingText
|
||||
content={cleanBlockContent}
|
||||
isStreaming={isActivelyStreaming}
|
||||
/>
|
||||
<SmoothStreamingText content={cleanBlockContent} isStreaming={isStreaming} />
|
||||
) : (
|
||||
<CopilotMarkdownRenderer content={cleanBlockContent} />
|
||||
)}
|
||||
@@ -231,7 +224,9 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
)
|
||||
}
|
||||
if (block.type === 'thinking') {
|
||||
// Check if there are any blocks after this one (tool calls, text, etc.)
|
||||
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 blockKey = `thinking-${index}-${block.timestamp || index}`
|
||||
|
||||
@@ -239,7 +234,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
<div key={blockKey} className='w-full'>
|
||||
<ThinkingBlock
|
||||
content={block.content}
|
||||
isStreaming={isActivelyStreaming}
|
||||
isStreaming={isStreaming}
|
||||
hasFollowingContent={hasFollowingContent}
|
||||
hasSpecialTags={hasSpecialTags}
|
||||
/>
|
||||
@@ -251,22 +246,18 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
|
||||
return (
|
||||
<div key={blockKey}>
|
||||
<ToolCall
|
||||
toolCallId={block.toolCall.id}
|
||||
toolCall={block.toolCall}
|
||||
isCurrentMessage={isLastMessage}
|
||||
/>
|
||||
<ToolCall toolCallId={block.toolCall.id} toolCall={block.toolCall} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})
|
||||
}, [message.contentBlocks, isActivelyStreaming, parsedTags, isLastMessage])
|
||||
}, [message.contentBlocks, isStreaming, parsedTags])
|
||||
|
||||
if (isUser) {
|
||||
return (
|
||||
<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}
|
||||
>
|
||||
{isEditMode ? (
|
||||
@@ -297,15 +288,42 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
initialContexts={message.contexts}
|
||||
/>
|
||||
|
||||
{/* Inline checkpoint confirmation - shown below input in edit mode */}
|
||||
{/* Inline Checkpoint Discard Confirmation - shown below input in edit mode */}
|
||||
{showCheckpointDiscardModal && (
|
||||
<CheckpointConfirmation
|
||||
variant='discard'
|
||||
isProcessing={isProcessingDiscard}
|
||||
onCancel={handleCancelCheckpointDiscard}
|
||||
onRevert={handleContinueAndRevert}
|
||||
onContinue={handleContinueWithoutRevert}
|
||||
/>
|
||||
<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)]'>
|
||||
Continue from a previous message?
|
||||
</p>
|
||||
<div className='flex gap-[8px]'>
|
||||
<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>
|
||||
) : (
|
||||
@@ -330,15 +348,46 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
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'}`}
|
||||
>
|
||||
{buildMentionHighlightNodes(
|
||||
message.content || '',
|
||||
message.contexts || [],
|
||||
(token, key) => (
|
||||
<span key={key} className='rounded-[4px] bg-[rgba(50,189,126,0.65)] py-[1px]'>
|
||||
{token}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
{(() => {
|
||||
const text = message.content || ''
|
||||
const contexts: any[] = Array.isArray((message as any).contexts)
|
||||
? ((message as any).contexts as any[])
|
||||
: []
|
||||
|
||||
// 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>
|
||||
|
||||
{/* Gradient fade when truncated - applies to entire message box */}
|
||||
@@ -388,30 +437,65 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inline restore checkpoint confirmation */}
|
||||
{/* Inline Restore Checkpoint Confirmation */}
|
||||
{showRestoreConfirmation && (
|
||||
<CheckpointConfirmation
|
||||
variant='restore'
|
||||
isProcessing={isReverting}
|
||||
onCancel={handleCancelRevert}
|
||||
onRevert={handleConfirmRevert}
|
||||
/>
|
||||
<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)]'>
|
||||
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>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
return (
|
||||
<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}
|
||||
>
|
||||
<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 */}
|
||||
{memoizedContentBlocks || (isStreaming && <div className='min-h-0' />)}
|
||||
{memoizedContentBlocks}
|
||||
|
||||
{isStreaming && <StreamingIndicator />}
|
||||
{isStreaming && (
|
||||
<StreamingIndicator className={!hasVisibleContent ? 'mt-1' : undefined} />
|
||||
)}
|
||||
|
||||
{message.errorType === 'usage_limit' && (
|
||||
<div className='flex gap-1.5'>
|
||||
@@ -450,7 +534,6 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
isLastMessage && !isStreaming && parsedTags.optionsComplete === true
|
||||
}
|
||||
streaming={isStreaming || !parsedTags.optionsComplete}
|
||||
selectedOptionKey={selectedOptionKey}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -461,22 +544,50 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
return null
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
// Custom comparison function for better streaming performance
|
||||
const prevMessage = prevProps.message
|
||||
const nextMessage = nextProps.message
|
||||
|
||||
if (prevMessage.id !== nextMessage.id) return false
|
||||
if (prevProps.isStreaming !== nextProps.isStreaming) return false
|
||||
if (prevProps.isDimmed !== nextProps.isDimmed) return false
|
||||
if (prevProps.panelWidth !== nextProps.panelWidth) return false
|
||||
if (prevProps.checkpointCount !== nextProps.checkpointCount) return false
|
||||
if (prevProps.isLastMessage !== nextProps.isLastMessage) return false
|
||||
// If message IDs are different, always re-render
|
||||
if (prevMessage.id !== nextMessage.id) {
|
||||
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) {
|
||||
const prevBlocks = prevMessage.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 => {
|
||||
for (let i = blocks.length - 1; i >= 0; i--) {
|
||||
const block = blocks[i]
|
||||
@@ -487,6 +598,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
return null
|
||||
}
|
||||
|
||||
// Re-render if the last text block content changed
|
||||
const prevLastTextContent = getLastBlockContent(prevBlocks as any[], 'text')
|
||||
const nextLastTextContent = getLastBlockContent(nextBlocks as any[], 'text')
|
||||
if (
|
||||
@@ -497,6 +609,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
return false
|
||||
}
|
||||
|
||||
// Re-render if the last thinking block content changed
|
||||
const prevLastThinkingContent = getLastBlockContent(prevBlocks as any[], 'thinking')
|
||||
const nextLastThinkingContent = getLastBlockContent(nextBlocks as any[], 'thinking')
|
||||
if (
|
||||
@@ -507,18 +620,24 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if tool calls changed
|
||||
const prevToolCalls = prevMessage.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++) {
|
||||
if (prevToolCalls[i]?.state !== nextToolCalls[i]?.state) return false
|
||||
if (prevToolCalls[i]?.state !== nextToolCalls[i]?.state) {
|
||||
return false // Tool call state changed
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// For non-streaming messages, do a deeper comparison including tool call states
|
||||
if (
|
||||
prevMessage.content !== nextMessage.content ||
|
||||
prevMessage.role !== nextMessage.role ||
|
||||
@@ -528,12 +647,16 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
return false
|
||||
}
|
||||
|
||||
// Check tool call states for non-streaming messages too
|
||||
const prevToolCalls = prevMessage.toolCalls || []
|
||||
const nextToolCalls = nextMessage.toolCalls || []
|
||||
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 nextContentBlocks = nextMessage.contentBlocks || []
|
||||
for (let i = 0; i < nextContentBlocks.length; i++) {
|
||||
@@ -544,7 +667,7 @@ const CopilotMessage: FC<CopilotMessageProps> = memo(
|
||||
nextBlock?.type === 'tool_call' &&
|
||||
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 onRevertModeChange - Callback for revert mode changes
|
||||
* @param onEditModeChange - Callback for edit mode changes
|
||||
* @param onCancelEdit - Callback when edit is cancelled
|
||||
* @returns Checkpoint management utilities
|
||||
*/
|
||||
export function useCheckpointManagement(
|
||||
@@ -38,13 +37,17 @@ export function useCheckpointManagement(
|
||||
|
||||
const { revertToCheckpoint, currentChat } = useCopilotStore()
|
||||
|
||||
/** Initiates checkpoint revert confirmation */
|
||||
/**
|
||||
* Handles initiating checkpoint revert
|
||||
*/
|
||||
const handleRevertToCheckpoint = useCallback(() => {
|
||||
setShowRestoreConfirmation(true)
|
||||
onRevertModeChange?.(true)
|
||||
}, [onRevertModeChange])
|
||||
|
||||
/** Confirms and executes checkpoint revert */
|
||||
/**
|
||||
* Confirms checkpoint revert and updates state
|
||||
*/
|
||||
const handleConfirmRevert = useCallback(async () => {
|
||||
if (messageCheckpoints.length > 0) {
|
||||
const latestCheckpoint = messageCheckpoints[0]
|
||||
@@ -113,13 +116,18 @@ export function useCheckpointManagement(
|
||||
onRevertModeChange,
|
||||
])
|
||||
|
||||
/** Cancels checkpoint revert */
|
||||
/**
|
||||
* Cancels checkpoint revert
|
||||
*/
|
||||
const handleCancelRevert = useCallback(() => {
|
||||
setShowRestoreConfirmation(false)
|
||||
onRevertModeChange?.(false)
|
||||
}, [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 () => {
|
||||
setIsProcessingDiscard(true)
|
||||
try {
|
||||
@@ -176,7 +184,9 @@ export function useCheckpointManagement(
|
||||
}
|
||||
}, [messageCheckpoints, revertToCheckpoint, message, messages, onEditModeChange, onCancelEdit])
|
||||
|
||||
/** Cancels checkpoint discard and clears pending edit */
|
||||
/**
|
||||
* Cancels checkpoint discard and clears pending edit
|
||||
*/
|
||||
const handleCancelCheckpointDiscard = useCallback(() => {
|
||||
setShowCheckpointDiscardModal(false)
|
||||
onEditModeChange?.(false)
|
||||
@@ -184,11 +194,11 @@ export function useCheckpointManagement(
|
||||
pendingEditRef.current = null
|
||||
}, [onEditModeChange, onCancelEdit])
|
||||
|
||||
/** Continues with edit without reverting checkpoint */
|
||||
/**
|
||||
* Continues with edit WITHOUT reverting checkpoint
|
||||
*/
|
||||
const handleContinueWithoutRevert = useCallback(async () => {
|
||||
setShowCheckpointDiscardModal(false)
|
||||
onEditModeChange?.(false)
|
||||
onCancelEdit?.()
|
||||
|
||||
if (pendingEditRef.current) {
|
||||
const { message: msg, fileAttachments, contexts } = pendingEditRef.current
|
||||
@@ -215,34 +225,43 @@ export function useCheckpointManagement(
|
||||
}
|
||||
}, [message, messages, onEditModeChange, onCancelEdit])
|
||||
|
||||
/** Handles keyboard events for confirmation dialogs */
|
||||
/**
|
||||
* Handles keyboard events for restore confirmation (Escape/Enter)
|
||||
*/
|
||||
useEffect(() => {
|
||||
const isActive = showRestoreConfirmation || showCheckpointDiscardModal
|
||||
if (!isActive) return
|
||||
if (!showRestoreConfirmation) return
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.defaultPrevented) return
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
if (showRestoreConfirmation) handleCancelRevert()
|
||||
else handleCancelCheckpointDiscard()
|
||||
handleCancelRevert()
|
||||
} else if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
if (showRestoreConfirmation) handleConfirmRevert()
|
||||
else handleContinueAndRevert()
|
||||
handleConfirmRevert()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [
|
||||
showRestoreConfirmation,
|
||||
showCheckpointDiscardModal,
|
||||
handleCancelRevert,
|
||||
handleConfirmRevert,
|
||||
handleCancelCheckpointDiscard,
|
||||
handleContinueAndRevert,
|
||||
])
|
||||
}, [showRestoreConfirmation, handleCancelRevert, handleConfirmRevert])
|
||||
|
||||
/**
|
||||
* Handles keyboard events for checkpoint discard modal (Escape/Enter)
|
||||
*/
|
||||
useEffect(() => {
|
||||
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 {
|
||||
// State
|
||||
|
||||
@@ -2,23 +2,24 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { ChatContext, CopilotMessage, MessageFileAttachment } from '@/stores/panel'
|
||||
import type { CopilotMessage } from '@/stores/panel'
|
||||
import { useCopilotStore } from '@/stores/panel'
|
||||
|
||||
const logger = createLogger('useMessageEditing')
|
||||
|
||||
/** Ref interface for UserInput component */
|
||||
interface UserInputRef {
|
||||
focus: () => void
|
||||
}
|
||||
|
||||
/** Message truncation height in pixels */
|
||||
/**
|
||||
* Message truncation height in pixels
|
||||
*/
|
||||
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
|
||||
|
||||
/** Delay before aborting when editing during stream */
|
||||
/**
|
||||
* Delay before aborting when editing during stream
|
||||
*/
|
||||
const ABORT_DELAY = 100
|
||||
|
||||
interface UseMessageEditingProps {
|
||||
@@ -31,8 +32,8 @@ interface UseMessageEditingProps {
|
||||
setShowCheckpointDiscardModal: (show: boolean) => void
|
||||
pendingEditRef: React.MutableRefObject<{
|
||||
message: string
|
||||
fileAttachments?: MessageFileAttachment[]
|
||||
contexts?: ChatContext[]
|
||||
fileAttachments?: any[]
|
||||
contexts?: any[]
|
||||
} | null>
|
||||
/**
|
||||
* When true, disables the internal document click-outside handler.
|
||||
@@ -68,11 +69,13 @@ export function useMessageEditing(props: UseMessageEditingProps) {
|
||||
|
||||
const editContainerRef = useRef<HTMLDivElement>(null)
|
||||
const messageContentRef = useRef<HTMLDivElement>(null)
|
||||
const userInputRef = useRef<UserInputRef>(null)
|
||||
const userInputRef = useRef<any>(null)
|
||||
|
||||
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(() => {
|
||||
if (messageContentRef.current && message.role === 'user') {
|
||||
const scrollHeight = messageContentRef.current.scrollHeight
|
||||
@@ -80,7 +83,9 @@ export function useMessageEditing(props: UseMessageEditingProps) {
|
||||
}
|
||||
}, [message.content, message.role])
|
||||
|
||||
/** Enters edit mode */
|
||||
/**
|
||||
* Handles entering edit mode
|
||||
*/
|
||||
const handleEditMessage = useCallback(() => {
|
||||
setIsEditMode(true)
|
||||
setIsExpanded(false)
|
||||
@@ -92,14 +97,18 @@ export function useMessageEditing(props: UseMessageEditingProps) {
|
||||
}, 0)
|
||||
}, [message.content, onEditModeChange])
|
||||
|
||||
/** Cancels edit mode */
|
||||
/**
|
||||
* Handles canceling edit mode
|
||||
*/
|
||||
const handleCancelEdit = useCallback(() => {
|
||||
setIsEditMode(false)
|
||||
setEditedContent(message.content)
|
||||
onEditModeChange?.(false)
|
||||
}, [message.content, onEditModeChange])
|
||||
|
||||
/** Handles message click to enter edit mode */
|
||||
/**
|
||||
* Handles clicking on message to enter edit mode
|
||||
*/
|
||||
const handleMessageClick = useCallback(() => {
|
||||
if (needsExpansion && !isExpanded) {
|
||||
setIsExpanded(true)
|
||||
@@ -107,13 +116,12 @@ export function useMessageEditing(props: UseMessageEditingProps) {
|
||||
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(
|
||||
async (
|
||||
editedMessage: string,
|
||||
fileAttachments?: MessageFileAttachment[],
|
||||
contexts?: ChatContext[]
|
||||
) => {
|
||||
async (editedMessage: string, fileAttachments?: any[], contexts?: any[]) => {
|
||||
const currentMessages = messages
|
||||
const editIndex = currentMessages.findIndex((m) => m.id === message.id)
|
||||
|
||||
@@ -126,7 +134,7 @@ export function useMessageEditing(props: UseMessageEditingProps) {
|
||||
...message,
|
||||
content: editedMessage,
|
||||
fileAttachments: fileAttachments || message.fileAttachments,
|
||||
contexts: contexts || message.contexts,
|
||||
contexts: contexts || (message as any).contexts,
|
||||
}
|
||||
|
||||
useCopilotStore.setState({ messages: [...truncatedMessages, updatedMessage] })
|
||||
@@ -145,7 +153,7 @@ export function useMessageEditing(props: UseMessageEditingProps) {
|
||||
timestamp: m.timestamp,
|
||||
...(m.contentBlocks && { contentBlocks: m.contentBlocks }),
|
||||
...(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, {
|
||||
fileAttachments: fileAttachments || message.fileAttachments,
|
||||
contexts: contexts || message.contexts,
|
||||
contexts: contexts || (message as any).contexts,
|
||||
messageId: message.id,
|
||||
queueIfBusy: false,
|
||||
})
|
||||
@@ -165,13 +173,12 @@ export function useMessageEditing(props: UseMessageEditingProps) {
|
||||
[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(
|
||||
async (
|
||||
editedMessage: string,
|
||||
fileAttachments?: MessageFileAttachment[],
|
||||
contexts?: ChatContext[]
|
||||
) => {
|
||||
async (editedMessage: string, fileAttachments?: any[], contexts?: any[]) => {
|
||||
if (!editedMessage.trim()) return
|
||||
|
||||
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(() => {
|
||||
if (!isEditMode) return
|
||||
|
||||
@@ -213,7 +222,9 @@ export function useMessageEditing(props: UseMessageEditingProps) {
|
||||
}
|
||||
}, [isEditMode, handleCancelEdit])
|
||||
|
||||
/** Optional document-level click-outside handler */
|
||||
/**
|
||||
* Optional document-level click-outside handler (disabled when parent manages it).
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isEditMode || disableDocumentClickOutside) return
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from './copilot-message'
|
||||
@@ -1,8 +1,7 @@
|
||||
export * from './chat-history-skeleton'
|
||||
export * from './copilot-message'
|
||||
export * from './plan-mode-section'
|
||||
export * from './queued-messages'
|
||||
export * from './todo-list'
|
||||
export * from './tool-call'
|
||||
export * from './user-input'
|
||||
export * from './welcome'
|
||||
export * from './copilot-message/copilot-message'
|
||||
export * from './plan-mode-section/plan-mode-section'
|
||||
export * from './queued-messages/queued-messages'
|
||||
export * from './todo-list/todo-list'
|
||||
export * from './tool-call/tool-call'
|
||||
export * from './user-input/user-input'
|
||||
export * from './welcome/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 { Trash } from '@/components/emcn/icons/trash'
|
||||
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
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from './queued-messages'
|
||||
@@ -31,22 +31,21 @@ export function QueuedMessages() {
|
||||
if (messageQueue.length === 0) return null
|
||||
|
||||
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 */}
|
||||
<button
|
||||
type='button'
|
||||
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 ? (
|
||||
<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='flex-shrink-0 font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||
{messageQueue.length}
|
||||
<span className='font-medium text-[var(--text-secondary)] text-xs'>
|
||||
{messageQueue.length} Queued
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
@@ -57,30 +56,30 @@ export function QueuedMessages() {
|
||||
{messageQueue.map((msg) => (
|
||||
<div
|
||||
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 */}
|
||||
<div className='flex h-[14px] w-[14px] shrink-0 items-center justify-center'>
|
||||
<div className='h-[10px] w-[10px] rounded-full border border-[var(--text-tertiary)]/50' />
|
||||
<div className='flex h-3 w-3 shrink-0 items-center justify-center'>
|
||||
<div className='h-2.5 w-2.5 rounded-full border border-[var(--text-tertiary)]/50' />
|
||||
</div>
|
||||
|
||||
{/* Message content */}
|
||||
<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>
|
||||
|
||||
{/* Actions - always visible */}
|
||||
<div className='flex shrink-0 items-center gap-[4px]'>
|
||||
<div className='flex shrink-0 items-center gap-0.5'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
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)'
|
||||
>
|
||||
<ArrowUp className='h-[14px] w-[14px]' />
|
||||
<ArrowUp className='h-3 w-3' />
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
@@ -88,10 +87,10 @@ export function QueuedMessages() {
|
||||
e.stopPropagation()
|
||||
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'
|
||||
>
|
||||
<Trash2 className='h-[14px] w-[14px]' />
|
||||
<Trash2 className='h-3 w-3' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from './todo-list'
|
||||
@@ -1 +0,0 @@
|
||||
export * from './tool-call'
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
hasInterrupt as hasInterruptFromConfig,
|
||||
isSpecialTool as isSpecialToolFromConfig,
|
||||
} 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 { 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'
|
||||
@@ -26,30 +26,27 @@ import { CLASS_TOOL_METADATA } from '@/stores/panel/copilot/store'
|
||||
import type { SubAgentContentBlock } from '@/stores/panel/copilot/types'
|
||||
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 }
|
||||
|
||||
/** 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 }
|
||||
|
||||
/** Result of parsing special XML tags from message content */
|
||||
interface ParsedTags {
|
||||
/** Parsed plan steps, keyed by step number */
|
||||
plan?: Record<string, PlanStep>
|
||||
/** Whether the plan tag is complete (has closing tag) */
|
||||
planComplete?: boolean
|
||||
/** Parsed options, keyed by option number */
|
||||
options?: Record<string, OptionItem>
|
||||
/** Whether the options tag is complete (has closing tag) */
|
||||
optionsComplete?: boolean
|
||||
/** Content with special tags removed */
|
||||
cleanContent: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts plan steps from plan_respond tool calls in subagent blocks.
|
||||
* @param blocks - The subagent content blocks to search
|
||||
* @returns Object containing steps in the format expected by PlanSteps component, and completion status
|
||||
* Extract plan steps from plan_respond tool calls in subagent blocks.
|
||||
* Returns { steps, isComplete } where steps is in the format expected by PlanSteps component.
|
||||
*/
|
||||
function extractPlanFromBlocks(blocks: SubAgentContentBlock[] | undefined): {
|
||||
steps: Record<string, PlanStep> | undefined
|
||||
@@ -57,6 +54,7 @@ function extractPlanFromBlocks(blocks: SubAgentContentBlock[] | undefined): {
|
||||
} {
|
||||
if (!blocks) return { steps: undefined, isComplete: false }
|
||||
|
||||
// Find the plan_respond tool call
|
||||
const planRespondBlock = blocks.find(
|
||||
(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 }
|
||||
}
|
||||
|
||||
// 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 args = tc.params || tc.parameters || tc.input || tc.arguments || tc.data?.arguments || {}
|
||||
const stepsArray = args.steps
|
||||
@@ -73,6 +73,9 @@ function extractPlanFromBlocks(blocks: SubAgentContentBlock[] | undefined): {
|
||||
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> = {}
|
||||
for (const step of stepsArray) {
|
||||
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 =
|
||||
planRespondBlock.toolCall.state === ClientToolCallState.success ||
|
||||
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.
|
||||
* @param jsonStr - The potentially incomplete JSON string
|
||||
* @returns Parsed options record or null if no valid options found
|
||||
* Try to parse partial JSON for streaming options.
|
||||
* Attempts to extract complete key-value pairs from incomplete JSON.
|
||||
*/
|
||||
function parsePartialOptionsJson(jsonStr: string): Record<string, OptionItem> | null {
|
||||
// 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
|
||||
// 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> = {}
|
||||
|
||||
// Match complete string values: "key": "value"
|
||||
const stringPattern = /"(\d+)":\s*"([^"]*?)"/g
|
||||
let match
|
||||
@@ -114,24 +116,18 @@ function parsePartialOptionsJson(jsonStr: string): Record<string, OptionItem> |
|
||||
result[match[1]] = match[2]
|
||||
}
|
||||
|
||||
// Match complete object values with title and optional description
|
||||
// Pattern matches: "1": {"title": "...", "description": "..."} or "1": {"title": "..."}
|
||||
const objectPattern =
|
||||
/"(\d+)":\s*\{\s*"title":\s*"((?:[^"\\]|\\.)*)"\s*(?:,\s*"description":\s*"((?:[^"\\]|\\.)*)")?\s*\}/g
|
||||
// Match complete object values: "key": {"title": "value"}
|
||||
const objectPattern = /"(\d+)":\s*\{[^}]*"title":\s*"([^"]*)"[^}]*\}/g
|
||||
while ((match = objectPattern.exec(jsonStr)) !== null) {
|
||||
const key = match[1]
|
||||
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 }
|
||||
result[match[1]] = { title: match[2] }
|
||||
}
|
||||
|
||||
return Object.keys(result).length > 0 ? result : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses partial JSON for streaming plan steps, extracting complete key-value pairs from incomplete JSON.
|
||||
* @param jsonStr - The potentially incomplete JSON string
|
||||
* @returns Parsed plan steps record or null if no valid steps found
|
||||
* Try to parse partial JSON for streaming plan steps.
|
||||
* Attempts to extract complete key-value pairs from incomplete JSON.
|
||||
*/
|
||||
function parsePartialPlanJson(jsonStr: string): Record<string, PlanStep> | null {
|
||||
// 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.
|
||||
* Handles both complete and streaming/incomplete tags.
|
||||
* @param content - The message content to parse
|
||||
* @returns Parsed tags with plan, options, and clean content
|
||||
* Parse <plan> and <options> tags from content
|
||||
*/
|
||||
export function parseSpecialTags(content: string): ParsedTags {
|
||||
const result: ParsedTags = { cleanContent: content }
|
||||
@@ -174,18 +167,12 @@ export function parseSpecialTags(content: string): ParsedTags {
|
||||
// Parse <plan> tag - check for complete tag first
|
||||
const planMatch = content.match(/<plan>([\s\S]*?)<\/plan>/i)
|
||||
if (planMatch) {
|
||||
// Always strip the tag from display, even if JSON is invalid
|
||||
result.cleanContent = result.cleanContent.replace(planMatch[0], '').trim()
|
||||
try {
|
||||
result.plan = JSON.parse(planMatch[1])
|
||||
result.planComplete = true
|
||||
result.cleanContent = result.cleanContent.replace(planMatch[0], '').trim()
|
||||
} catch {
|
||||
// JSON.parse failed - use regex fallback to extract plan from malformed JSON
|
||||
const fallbackPlan = parsePartialPlanJson(planMatch[1])
|
||||
if (fallbackPlan) {
|
||||
result.plan = fallbackPlan
|
||||
result.planComplete = true
|
||||
}
|
||||
// Invalid JSON, ignore
|
||||
}
|
||||
} else {
|
||||
// Check for streaming/incomplete plan tag
|
||||
@@ -204,18 +191,12 @@ export function parseSpecialTags(content: string): ParsedTags {
|
||||
// Parse <options> tag - check for complete tag first
|
||||
const optionsMatch = content.match(/<options>([\s\S]*?)<\/options>/i)
|
||||
if (optionsMatch) {
|
||||
// Always strip the tag from display, even if JSON is invalid
|
||||
result.cleanContent = result.cleanContent.replace(optionsMatch[0], '').trim()
|
||||
try {
|
||||
result.options = JSON.parse(optionsMatch[1])
|
||||
result.optionsComplete = true
|
||||
result.cleanContent = result.cleanContent.replace(optionsMatch[0], '').trim()
|
||||
} catch {
|
||||
// JSON.parse failed - use regex fallback to extract options from malformed JSON
|
||||
const fallbackOptions = parsePartialOptionsJson(optionsMatch[1])
|
||||
if (fallbackOptions) {
|
||||
result.options = fallbackOptions
|
||||
result.optionsComplete = true
|
||||
}
|
||||
// Invalid JSON, ignore
|
||||
}
|
||||
} else {
|
||||
// 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.
|
||||
* @param steps - Plan steps keyed by step number
|
||||
* @param streaming - When true, uses smooth streaming animation for step titles
|
||||
* PlanSteps component renders the workflow plan steps from the plan subagent
|
||||
* Displays as a to-do list with checkmarks and strikethrough text
|
||||
*/
|
||||
function PlanSteps({
|
||||
steps,
|
||||
streaming = false,
|
||||
}: {
|
||||
steps: Record<string, PlanStep>
|
||||
/** When true, uses smooth streaming animation for step titles */
|
||||
streaming?: boolean
|
||||
}) {
|
||||
const sortedSteps = useMemo(() => {
|
||||
@@ -268,7 +249,7 @@ function PlanSteps({
|
||||
if (sortedSteps.length === 0) return null
|
||||
|
||||
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]'>
|
||||
<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>
|
||||
@@ -276,7 +257,7 @@ function PlanSteps({
|
||||
{sortedSteps.length}
|
||||
</span>
|
||||
</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) => {
|
||||
const isLastStep = index === sortedSteps.length - 1
|
||||
return (
|
||||
@@ -300,8 +281,9 @@ function PlanSteps({
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders selectable options from the agent with keyboard navigation and click selection.
|
||||
* After selection, shows the chosen option highlighted and others struck through.
|
||||
* OptionsSelector component renders selectable options from the agent
|
||||
* Supports keyboard navigation (arrow up/down, enter) and click selection
|
||||
* After selection, shows the chosen option highlighted and others struck through
|
||||
*/
|
||||
export function OptionsSelector({
|
||||
options,
|
||||
@@ -309,7 +291,6 @@ export function OptionsSelector({
|
||||
disabled = false,
|
||||
enableKeyboardNav = false,
|
||||
streaming = false,
|
||||
selectedOptionKey = null,
|
||||
}: {
|
||||
options: Record<string, OptionItem>
|
||||
onSelect: (optionKey: string, optionText: string) => void
|
||||
@@ -318,8 +299,6 @@ export function OptionsSelector({
|
||||
enableKeyboardNav?: boolean
|
||||
/** When true, looks enabled but interaction is disabled (for streaming state) */
|
||||
streaming?: boolean
|
||||
/** Pre-selected option key (for restoring selection from history) */
|
||||
selectedOptionKey?: string | null
|
||||
}) {
|
||||
const isInteractionDisabled = disabled || streaming
|
||||
const sortedOptions = useMemo(() => {
|
||||
@@ -337,8 +316,8 @@ export function OptionsSelector({
|
||||
})
|
||||
}, [options])
|
||||
|
||||
const [hoveredIndex, setHoveredIndex] = useState(-1)
|
||||
const [chosenKey, setChosenKey] = useState<string | null>(selectedOptionKey)
|
||||
const [hoveredIndex, setHoveredIndex] = useState(0)
|
||||
const [chosenKey, setChosenKey] = useState<string | null>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const isLocked = chosenKey !== null
|
||||
@@ -348,8 +327,7 @@ export function OptionsSelector({
|
||||
if (isInteractionDisabled || !enableKeyboardNav || isLocked) return
|
||||
|
||||
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 isInputFocused =
|
||||
activeElement?.tagName === 'INPUT' ||
|
||||
@@ -360,14 +338,13 @@ export function OptionsSelector({
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
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') {
|
||||
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') {
|
||||
e.preventDefault()
|
||||
const indexToSelect = hoveredIndex < 0 ? 0 : hoveredIndex
|
||||
const selected = sortedOptions[indexToSelect]
|
||||
const selected = sortedOptions[hoveredIndex]
|
||||
if (selected) {
|
||||
setChosenKey(selected.key)
|
||||
onSelect(selected.key, selected.title)
|
||||
@@ -391,7 +368,7 @@ export function OptionsSelector({
|
||||
if (sortedOptions.length === 0) return null
|
||||
|
||||
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) => {
|
||||
const isHovered = index === hoveredIndex && !isLocked
|
||||
const isChosen = option.key === chosenKey
|
||||
@@ -409,9 +386,6 @@ export function OptionsSelector({
|
||||
onMouseEnter={() => {
|
||||
if (!isLocked && !streaming) setHoveredIndex(index)
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (!isLocked && !streaming && sortedOptions.length === 1) setHoveredIndex(-1)
|
||||
}}
|
||||
className={clsx(
|
||||
'group flex cursor-pointer items-start gap-2 rounded-[6px] p-1',
|
||||
'hover:bg-[var(--surface-4)]',
|
||||
@@ -447,31 +421,30 @@ export function OptionsSelector({
|
||||
)
|
||||
}
|
||||
|
||||
/** Props for the ToolCall component */
|
||||
interface ToolCallProps {
|
||||
/** Tool call data object */
|
||||
toolCall?: CopilotToolCall
|
||||
/** Tool call ID for store lookup */
|
||||
toolCallId?: string
|
||||
/** Callback when tool call state changes */
|
||||
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 {
|
||||
/** Text content to display */
|
||||
/** The text content to display */
|
||||
text: string
|
||||
/** Whether shimmer animation is active */
|
||||
/** Whether the shimmer animation is active */
|
||||
active?: boolean
|
||||
/** Additional class names for the wrapper */
|
||||
className?: string
|
||||
/** Whether to use special gradient styling for important actions */
|
||||
/** Whether to use special gradient styling (for important actions) */
|
||||
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 = [
|
||||
'Analyzing',
|
||||
'Analyzed',
|
||||
@@ -579,8 +552,7 @@ const ACTION_VERBS = [
|
||||
|
||||
/**
|
||||
* Splits text into action verb and remainder for two-tone rendering.
|
||||
* @param text - The text to split
|
||||
* @returns Tuple of [actionVerb, remainder] or [null, text] if no match
|
||||
* Returns [actionVerb, remainder] or [null, text] if no match.
|
||||
*/
|
||||
function splitActionVerb(text: string): [string | null, string] {
|
||||
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.
|
||||
* Special tools use a gradient color; normal tools highlight action verbs.
|
||||
* Uses CSS truncation to clamp to one line with ellipsis.
|
||||
* Renders text with a subtle white shimmer overlay when active, creating a skeleton-like
|
||||
* loading effect that passes over the existing words without replacing them.
|
||||
* 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({
|
||||
text,
|
||||
@@ -612,13 +585,10 @@ const ShimmerOverlayText = memo(function ShimmerOverlayText({
|
||||
}: ShimmerOverlayTextProps) {
|
||||
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
|
||||
if (isSpecial) {
|
||||
return (
|
||||
<span className={`relative ${truncateClasses} ${className || ''}`}>
|
||||
<span className={`relative inline-block ${className || ''}`}>
|
||||
<span className='text-[var(--brand-tertiary-2)]'>{text}</span>
|
||||
{active ? (
|
||||
<span
|
||||
@@ -626,7 +596,7 @@ const ShimmerOverlayText = memo(function ShimmerOverlayText({
|
||||
className='pointer-events-none absolute inset-0 select-none overflow-hidden'
|
||||
>
|
||||
<span
|
||||
className='block overflow-hidden text-ellipsis whitespace-nowrap text-transparent'
|
||||
className='block text-transparent'
|
||||
style={{
|
||||
backgroundImage:
|
||||
'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
|
||||
// Dark mode: tertiary (#b3b3b3) vs muted (#787878) for good contrast
|
||||
return (
|
||||
<span className={`relative ${truncateClasses} ${className || ''}`}>
|
||||
<span className={`relative inline-block ${className || ''}`}>
|
||||
{actionVerb ? (
|
||||
<>
|
||||
<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'
|
||||
>
|
||||
<span
|
||||
className='block overflow-hidden text-ellipsis whitespace-nowrap text-transparent'
|
||||
className='block text-transparent'
|
||||
style={{
|
||||
backgroundImage:
|
||||
'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.
|
||||
* @param toolName - The tool name to get the label for
|
||||
* @returns The completion label from UI config, defaults to 'Thought'
|
||||
* Get the outer collapse header label for completed subagent tools.
|
||||
* Uses the tool's UI config.
|
||||
*/
|
||||
function getSubagentCompletionLabel(toolName: string): string {
|
||||
const labels = getSubagentLabelsFromConfig(toolName, false)
|
||||
@@ -712,9 +681,8 @@ function getSubagentCompletionLabel(toolName: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders subagent blocks as thinking text within regular tool calls.
|
||||
* @param blocks - The subagent content blocks to render
|
||||
* @param isStreaming - Whether streaming animations should be shown (caller should pre-compute currentMessage check)
|
||||
* SubAgentThinkingContent renders subagent blocks as simple thinking text (ThinkingBlock).
|
||||
* Used for inline rendering within regular tool calls that have subagent content.
|
||||
*/
|
||||
function SubAgentThinkingContent({
|
||||
blocks,
|
||||
@@ -749,7 +717,7 @@ function SubAgentThinkingContent({
|
||||
const hasSpecialTags = hasPlan
|
||||
|
||||
return (
|
||||
<div className='space-y-[4px]'>
|
||||
<div className='space-y-1.5'>
|
||||
{cleanText.trim() && (
|
||||
<ThinkingBlock
|
||||
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'])
|
||||
|
||||
/**
|
||||
* 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({
|
||||
toolCall,
|
||||
shouldCollapse,
|
||||
isCurrentMessage = true,
|
||||
}: {
|
||||
toolCall: CopilotToolCall
|
||||
shouldCollapse: boolean
|
||||
/** Whether this is from the current/latest message. Controls shimmer animations. */
|
||||
isCurrentMessage?: boolean
|
||||
}) {
|
||||
const [isExpanded, setIsExpanded] = useState(true)
|
||||
const [duration, setDuration] = useState(0)
|
||||
const startTimeRef = useRef<number>(Date.now())
|
||||
const wasStreamingRef = useRef(false)
|
||||
|
||||
// Only show streaming animations for current message
|
||||
const isStreaming = isCurrentMessage && !!toolCall.subAgentStreaming
|
||||
const isStreaming = !!toolCall.subAgentStreaming
|
||||
|
||||
useEffect(() => {
|
||||
if (isStreaming && !wasStreamingRef.current) {
|
||||
@@ -879,11 +850,7 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
|
||||
}
|
||||
return (
|
||||
<div key={`tool-${segment.block.toolCall.id || index}`}>
|
||||
<ToolCall
|
||||
toolCallId={segment.block.toolCall.id}
|
||||
toolCall={segment.block.toolCall}
|
||||
isCurrentMessage={isCurrentMessage}
|
||||
/>
|
||||
<ToolCall toolCallId={segment.block.toolCall.id} toolCall={segment.block.toolCall} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -894,7 +861,7 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
|
||||
|
||||
if (isStreaming || !shouldCollapse) {
|
||||
return (
|
||||
<div className='w-full space-y-[4px]'>
|
||||
<div className='w-full space-y-1.5'>
|
||||
{renderCollapsibleContent()}
|
||||
{hasPlan && planToRender && <PlanSteps steps={planToRender} streaming={isPlanStreaming} />}
|
||||
</div>
|
||||
@@ -921,30 +888,30 @@ const SubagentContentRenderer = memo(function SubagentContentRenderer({
|
||||
<div
|
||||
className={clsx(
|
||||
'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()}
|
||||
</div>
|
||||
|
||||
{hasPlan && planToRender && (
|
||||
<div className='mt-[6px]'>
|
||||
<PlanSteps steps={planToRender} />
|
||||
</div>
|
||||
)}
|
||||
{/* Plan stays outside the collapsible */}
|
||||
{hasPlan && planToRender && <PlanSteps steps={planToRender} />}
|
||||
</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 {
|
||||
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({
|
||||
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 {
|
||||
return !CLASS_TOOL_METADATA[toolName]
|
||||
}
|
||||
@@ -1348,7 +1317,9 @@ function getDisplayName(toolCall: CopilotToolCall): string {
|
||||
return `${stateVerb} ${formattedName}`
|
||||
}
|
||||
|
||||
/** Gets verb prefix based on tool call state */
|
||||
/**
|
||||
* Get verb prefix based on tool state
|
||||
*/
|
||||
function getStateVerb(state: string): string {
|
||||
switch (state) {
|
||||
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 {
|
||||
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
|
||||
return (
|
||||
<div className='mt-[10px] flex gap-[6px]'>
|
||||
<div className='mt-1.5 flex gap-[6px]'>
|
||||
<Button onClick={onRun} disabled={isProcessing} variant='tertiary'>
|
||||
{isProcessing ? 'Allowing...' : 'Allow'}
|
||||
</Button>
|
||||
@@ -1459,12 +1431,7 @@ function RunSkipButtons({
|
||||
)
|
||||
}
|
||||
|
||||
export function ToolCall({
|
||||
toolCall: toolCallProp,
|
||||
toolCallId,
|
||||
onStateChange,
|
||||
isCurrentMessage = true,
|
||||
}: ToolCallProps) {
|
||||
export function ToolCall({ toolCall: toolCallProp, toolCallId, onStateChange }: ToolCallProps) {
|
||||
const [, forceUpdate] = useState({})
|
||||
// Get live toolCall from store to ensure we have the latest state
|
||||
const effectiveId = toolCallId || toolCallProp?.id
|
||||
@@ -1478,7 +1445,9 @@ export function ToolCall({
|
||||
|
||||
const isExpandablePending =
|
||||
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 [showRemoveAutoAllow, setShowRemoveAutoAllow] = useState(false)
|
||||
@@ -1553,7 +1522,6 @@ export function ToolCall({
|
||||
<SubagentContentRenderer
|
||||
toolCall={toolCall}
|
||||
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)
|
||||
const hasParamsTable = !!getToolUIConfig(toolCall.name)?.paramsTable
|
||||
const isRunWorkflow = toolCall.name === 'run_workflow'
|
||||
const isExpandableTool =
|
||||
hasParamsTable ||
|
||||
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 secondaryAction = toolUIConfig?.secondaryAction
|
||||
const showSecondaryAction = secondaryAction?.showInStates.includes(
|
||||
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
|
||||
const showMoveToBackground =
|
||||
isCurrentMessage &&
|
||||
((showSecondaryAction && secondaryAction?.text === 'Move to Background') ||
|
||||
(!secondaryAction && toolCall.name === 'run_workflow' && isExecuting))
|
||||
showSecondaryAction && secondaryAction?.text === 'Move to Background'
|
||||
? true
|
||||
: !secondaryAction &&
|
||||
toolCall.name === 'run_workflow' &&
|
||||
(toolCall.state === (ClientToolCallState.executing as any) ||
|
||||
toolCall.state === ('executing' as any))
|
||||
|
||||
const showWake =
|
||||
isCurrentMessage &&
|
||||
((showSecondaryAction && secondaryAction?.text === 'Wake') ||
|
||||
(!secondaryAction && toolCall.name === 'sleep' && isExecuting))
|
||||
showSecondaryAction && secondaryAction?.text === 'Wake'
|
||||
? true
|
||||
: !secondaryAction &&
|
||||
toolCall.name === 'sleep' &&
|
||||
(toolCall.state === (ClientToolCallState.executing as any) ||
|
||||
toolCall.state === ('executing' as any))
|
||||
|
||||
const handleStateChange = (state: any) => {
|
||||
forceUpdate({})
|
||||
@@ -1623,8 +1594,6 @@ export function ToolCall({
|
||||
toolCall.state === ClientToolCallState.pending ||
|
||||
toolCall.state === ClientToolCallState.executing
|
||||
|
||||
const shouldShowShimmer = isCurrentMessage && isLoadingState
|
||||
|
||||
const isSpecial = isSpecialToolCall(toolCall)
|
||||
|
||||
const renderPendingDetails = () => {
|
||||
@@ -1934,7 +1903,7 @@ export function ToolCall({
|
||||
</span>
|
||||
</div>
|
||||
{/* Input entries */}
|
||||
<div className='flex flex-col pt-[6px]'>
|
||||
<div className='flex flex-col'>
|
||||
{inputEntries.map(([key, value], index) => {
|
||||
const isComplex = isComplexValue(value)
|
||||
const displayValue = formatValueForDisplay(value)
|
||||
@@ -1943,8 +1912,8 @@ export function ToolCall({
|
||||
<div
|
||||
key={key}
|
||||
className={clsx(
|
||||
'flex flex-col gap-[6px] px-[10px] pb-[6px]',
|
||||
index > 0 && 'mt-[6px] border-[var(--border-1)] border-t pt-[6px]'
|
||||
'flex flex-col gap-1.5 px-[10px] py-[8px]',
|
||||
index > 0 && 'border-[var(--border-1)] border-t'
|
||||
)}
|
||||
>
|
||||
{/* Input key */}
|
||||
@@ -2036,14 +2005,14 @@ export function ToolCall({
|
||||
<div className={isEnvVarsClickable ? 'cursor-pointer' : ''} onClick={handleEnvVarsClick}>
|
||||
<ShimmerOverlayText
|
||||
text={displayName}
|
||||
active={shouldShowShimmer}
|
||||
active={isLoadingState}
|
||||
isSpecial={isSpecial}
|
||||
className='font-[470] font-season text-[var(--text-secondary)] text-sm dark:text-[var(--text-muted)]'
|
||||
/>
|
||||
</div>
|
||||
<div className='mt-[10px]'>{renderPendingDetails()}</div>
|
||||
<div className='mt-1.5'>{renderPendingDetails()}</div>
|
||||
{showRemoveAutoAllow && isAutoAllowed && (
|
||||
<div className='mt-[10px]'>
|
||||
<div className='mt-1.5'>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await removeAutoAllowedTool(toolCall.name)
|
||||
@@ -2068,7 +2037,7 @@ export function ToolCall({
|
||||
{toolCall.subAgentBlocks && toolCall.subAgentBlocks.length > 0 && (
|
||||
<SubAgentThinkingContent
|
||||
blocks={toolCall.subAgentBlocks}
|
||||
isStreaming={isCurrentMessage && toolCall.subAgentStreaming}
|
||||
isStreaming={toolCall.subAgentStreaming}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -2093,18 +2062,18 @@ export function ToolCall({
|
||||
>
|
||||
<ShimmerOverlayText
|
||||
text={displayName}
|
||||
active={shouldShowShimmer}
|
||||
active={isLoadingState}
|
||||
isSpecial={isSpecial}
|
||||
className='font-[470] font-season text-[var(--text-secondary)] text-sm dark:text-[var(--text-muted)]'
|
||||
/>
|
||||
</div>
|
||||
{code && (
|
||||
<div className='mt-[10px]'>
|
||||
<div className='mt-1.5'>
|
||||
<Code.Viewer code={code} language='javascript' showGutter className='min-h-0' />
|
||||
</div>
|
||||
)}
|
||||
{showRemoveAutoAllow && isAutoAllowed && (
|
||||
<div className='mt-[10px]'>
|
||||
<div className='mt-1.5'>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await removeAutoAllowedTool(toolCall.name)
|
||||
@@ -2129,14 +2098,14 @@ export function ToolCall({
|
||||
{toolCall.subAgentBlocks && toolCall.subAgentBlocks.length > 0 && (
|
||||
<SubAgentThinkingContent
|
||||
blocks={toolCall.subAgentBlocks}
|
||||
isStreaming={isCurrentMessage && toolCall.subAgentStreaming}
|
||||
isStreaming={toolCall.subAgentStreaming}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const isToolNameClickable = (!isRunWorkflow && isExpandableTool) || isAutoAllowed
|
||||
const isToolNameClickable = isExpandableTool || isAutoAllowed
|
||||
|
||||
const handleToolNameClick = () => {
|
||||
if (isExpandableTool) {
|
||||
@@ -2147,7 +2116,6 @@ export function ToolCall({
|
||||
}
|
||||
|
||||
const isEditWorkflow = toolCall.name === 'edit_workflow'
|
||||
const shouldShowDetails = isRunWorkflow || (isExpandableTool && expanded)
|
||||
const hasOperations = Array.isArray(params.operations) && params.operations.length > 0
|
||||
const hideTextForEditWorkflow = isEditWorkflow && hasOperations
|
||||
|
||||
@@ -2157,15 +2125,15 @@ export function ToolCall({
|
||||
<div className={isToolNameClickable ? 'cursor-pointer' : ''} onClick={handleToolNameClick}>
|
||||
<ShimmerOverlayText
|
||||
text={displayName}
|
||||
active={shouldShowShimmer}
|
||||
active={isLoadingState}
|
||||
isSpecial={isSpecial}
|
||||
className='font-[470] font-season text-[var(--text-secondary)] text-sm dark:text-[var(--text-muted)]'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{shouldShowDetails && <div className='mt-[10px]'>{renderPendingDetails()}</div>}
|
||||
{isExpandableTool && expanded && <div className='mt-1.5'>{renderPendingDetails()}</div>}
|
||||
{showRemoveAutoAllow && isAutoAllowed && (
|
||||
<div className='mt-[10px]'>
|
||||
<div className='mt-1.5'>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await removeAutoAllowedTool(toolCall.name)
|
||||
@@ -2186,7 +2154,7 @@ export function ToolCall({
|
||||
editedParams={editedParams}
|
||||
/>
|
||||
) : showMoveToBackground ? (
|
||||
<div className='mt-[10px]'>
|
||||
<div className='mt-1.5'>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
try {
|
||||
@@ -2207,7 +2175,7 @@ export function ToolCall({
|
||||
</Button>
|
||||
</div>
|
||||
) : showWake ? (
|
||||
<div className='mt-[10px]'>
|
||||
<div className='mt-1.5'>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
try {
|
||||
@@ -2240,7 +2208,7 @@ export function ToolCall({
|
||||
{toolCall.subAgentBlocks && toolCall.subAgentBlocks.length > 0 && (
|
||||
<SubAgentThinkingContent
|
||||
blocks={toolCall.subAgentBlocks}
|
||||
isStreaming={isCurrentMessage && toolCall.subAgentStreaming}
|
||||
isStreaming={toolCall.subAgentStreaming}
|
||||
/>
|
||||
)}
|
||||
</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 { BottomControls } from './bottom-controls'
|
||||
export { ContextPills } from './context-pills'
|
||||
export { type MentionFolderNav, MentionMenu } from './mention-menu'
|
||||
export { ModeSelector } from './mode-selector'
|
||||
export { ModelSelector } from './model-selector'
|
||||
export { type SlashFolderNav, SlashMenu } from './slash-menu'
|
||||
export { AttachedFilesDisplay } from './attached-files-display/attached-files-display'
|
||||
export { ContextPills } from './context-pills/context-pills'
|
||||
export { type MentionFolderNav, MentionMenu } from './mention-menu/mention-menu'
|
||||
export { ModeSelector } from './mode-selector/mode-selector'
|
||||
export { ModelSelector } from './model-selector/model-selector'
|
||||
export { type SlashFolderNav, SlashMenu } from './slash-menu/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 {
|
||||
escapeRegex,
|
||||
filterOutContext,
|
||||
isContextAlreadySelected,
|
||||
} 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) {
|
||||
const [selectedContexts, setSelectedContexts] = useState<ChatContext[]>(initialContexts ?? [])
|
||||
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)
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from './user-input'
|
||||
@@ -9,19 +9,19 @@ import {
|
||||
useState,
|
||||
} from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { AtSign } from 'lucide-react'
|
||||
import { ArrowUp, AtSign, Image, Loader2 } from 'lucide-react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Badge, Button, Textarea } from '@/components/emcn'
|
||||
import { useSession } from '@/lib/auth/auth-client'
|
||||
import type { CopilotModelId } from '@/lib/copilot/models'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import {
|
||||
AttachedFilesDisplay,
|
||||
BottomControls,
|
||||
ContextPills,
|
||||
type MentionFolderNav,
|
||||
MentionMenu,
|
||||
ModelSelector,
|
||||
ModeSelector,
|
||||
type SlashFolderNav,
|
||||
SlashMenu,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/components'
|
||||
@@ -44,10 +44,6 @@ import {
|
||||
useTextareaAutoResize,
|
||||
} 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 {
|
||||
computeMentionHighlightRanges,
|
||||
extractContextTokens,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/user-input/utils'
|
||||
import type { ChatContext } from '@/stores/panel'
|
||||
import { useCopilotStore } from '@/stores/panel'
|
||||
|
||||
@@ -267,6 +263,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
|
||||
if (q && q.length > 0) {
|
||||
void mentionData.ensurePastChatsLoaded()
|
||||
// workflows and workflow-blocks auto-load from stores
|
||||
void mentionData.ensureKnowledgeLoaded()
|
||||
void mentionData.ensureBlocksLoaded()
|
||||
void mentionData.ensureTemplatesLoaded()
|
||||
@@ -309,7 +306,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
size: f.size,
|
||||
}))
|
||||
|
||||
onSubmit(trimmedMessage, fileAttachmentsForApi, contextManagement.selectedContexts)
|
||||
onSubmit(trimmedMessage, fileAttachmentsForApi, contextManagement.selectedContexts as any)
|
||||
|
||||
const shouldClearInput = clearOnSubmit && !options.preserveInput && !overrideMessage
|
||||
if (shouldClearInput) {
|
||||
@@ -660,7 +657,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
|
||||
const handleModelSelect = useCallback(
|
||||
(model: string) => {
|
||||
setSelectedModel(model as CopilotModelId)
|
||||
setSelectedModel(model as any)
|
||||
},
|
||||
[setSelectedModel]
|
||||
)
|
||||
@@ -680,17 +677,15 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
return <span>{displayText}</span>
|
||||
}
|
||||
|
||||
const tokens = extractContextTokens(contexts)
|
||||
const ranges = computeMentionHighlightRanges(message, tokens)
|
||||
const elements: React.ReactNode[] = []
|
||||
const ranges = mentionTokensWithContext.computeMentionRanges()
|
||||
|
||||
if (ranges.length === 0) {
|
||||
const displayText = message.endsWith('\n') ? `${message}\u200B` : message
|
||||
return <span>{displayText}</span>
|
||||
}
|
||||
|
||||
const elements: React.ReactNode[] = []
|
||||
let lastIndex = 0
|
||||
|
||||
for (let i = 0; i < ranges.length; 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>)
|
||||
}
|
||||
|
||||
const mentionText = message.slice(range.start, range.end)
|
||||
elements.push(
|
||||
<span
|
||||
key={`mention-${i}-${range.start}-${range.end}`}
|
||||
className='rounded-[4px] bg-[rgba(50,189,126,0.65)] py-[1px]'
|
||||
>
|
||||
{range.token}
|
||||
{mentionText}
|
||||
</span>
|
||||
)
|
||||
lastIndex = range.end
|
||||
@@ -717,7 +713,7 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
}
|
||||
|
||||
return elements.length > 0 ? elements : <span>{'\u00A0'}</span>
|
||||
}, [message, contextManagement.selectedContexts])
|
||||
}, [message, contextManagement.selectedContexts, mentionTokensWithContext])
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -859,22 +855,87 @@ const UserInput = forwardRef<UserInputRef, UserInputProps>(
|
||||
</div>
|
||||
|
||||
{/* Bottom Row: Mode Selector + Model Selector + Attach Button + Send Button */}
|
||||
<BottomControls
|
||||
mode={mode}
|
||||
onModeChange={onModeChange}
|
||||
selectedModel={selectedModel}
|
||||
onModelSelect={handleModelSelect}
|
||||
isNearTop={isNearTop}
|
||||
disabled={disabled}
|
||||
hideModeSelector={hideModeSelector}
|
||||
canSubmit={canSubmit}
|
||||
isLoading={isLoading}
|
||||
isAborting={isAborting}
|
||||
showAbortButton={Boolean(showAbortButton)}
|
||||
onSubmit={() => void handleSubmit()}
|
||||
onAbort={handleAbort}
|
||||
onFileSelect={fileAttachments.handleFileSelect}
|
||||
/>
|
||||
<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={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 */}
|
||||
<input
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
FOLDER_CONFIGS,
|
||||
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 { 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.
|
||||
* 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'
|
||||
|
||||
/** Props for the Welcome component */
|
||||
/**
|
||||
* Props for the CopilotWelcome component
|
||||
*/
|
||||
interface WelcomeProps {
|
||||
/** Callback when a suggested question is clicked */
|
||||
onQuestionClick?: (question: string) => void
|
||||
@@ -10,7 +12,13 @@ interface WelcomeProps {
|
||||
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) {
|
||||
const capabilities =
|
||||
mode === 'build'
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
import { Trash } from '@/components/emcn/icons/trash'
|
||||
import { cn } from '@/lib/core/utils/cn'
|
||||
import {
|
||||
ChatHistorySkeleton,
|
||||
CopilotMessage,
|
||||
PlanModeSection,
|
||||
QueuedMessages,
|
||||
@@ -41,7 +40,6 @@ import {
|
||||
useTodoManagement,
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/hooks'
|
||||
import { useScrollManagement } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
import type { ChatContext } from '@/stores/panel'
|
||||
import { useCopilotStore } from '@/stores/panel'
|
||||
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 cancelEditCallbackRef = useRef<(() => void) | null>(null)
|
||||
const [editingMessageId, setEditingMessageId] = useState<string | null>(null)
|
||||
const [isEditingMessage, setIsEditingMessage] = useState(false)
|
||||
const [revertingMessageId, setRevertingMessageId] = useState<string | null>(null)
|
||||
const [isHistoryDropdownOpen, setIsHistoryDropdownOpen] = useState(false)
|
||||
|
||||
// Derived state - editing when there's an editingMessageId
|
||||
const isEditingMessage = editingMessageId !== null
|
||||
|
||||
const { activeWorkflowId } = useWorkflowRegistry()
|
||||
|
||||
const {
|
||||
@@ -110,9 +106,9 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
areChatsFresh,
|
||||
workflowId: copilotWorkflowId,
|
||||
setPlanTodos,
|
||||
closePlanTodos,
|
||||
clearPlanArtifact,
|
||||
savePlanArtifact,
|
||||
setSelectedModel,
|
||||
loadAutoAllowedTools,
|
||||
} = useCopilotStore()
|
||||
|
||||
@@ -130,7 +126,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
|
||||
// Handle scroll management (80px stickiness for copilot)
|
||||
const { scrollAreaRef, scrollToBottom } = useScrollManagement(messages, isSendingMessage, {
|
||||
stickinessThreshold: 40,
|
||||
stickinessThreshold: 80,
|
||||
})
|
||||
|
||||
// Handle chat history grouping
|
||||
@@ -150,10 +146,15 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
isSendingMessage,
|
||||
showPlanTodos,
|
||||
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(() => {
|
||||
// Use streaming content if available
|
||||
if (streamingPlanContent) {
|
||||
logger.info('[DesignDocument] Using streaming plan content', {
|
||||
contentLength: streamingPlanContent.length,
|
||||
@@ -164,7 +165,9 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
return ''
|
||||
}, [streamingPlanContent])
|
||||
|
||||
/** Focuses the copilot input */
|
||||
/**
|
||||
* Helper function to focus the copilot input
|
||||
*/
|
||||
const focusInput = useCallback(() => {
|
||||
userInputRef.current?.focus()
|
||||
}, [])
|
||||
@@ -178,14 +181,20 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
currentInputValue: inputValue,
|
||||
})
|
||||
|
||||
/** Auto-scrolls to bottom when chat loads */
|
||||
/**
|
||||
* Auto-scroll to bottom when chat loads in
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (isInitialized && messages.length > 0) {
|
||||
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)
|
||||
isSendingRef.current = isSendingMessage
|
||||
const abortMessageRef = useRef(abortMessage)
|
||||
@@ -193,15 +202,19 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// Use refs to check current values, not stale closure values
|
||||
if (isSendingRef.current) {
|
||||
abortMessageRef.current()
|
||||
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
|
||||
}, [])
|
||||
|
||||
/** 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(
|
||||
(event: ReactMouseEvent<HTMLDivElement>) => {
|
||||
if (!isEditingMessage) return
|
||||
@@ -230,7 +243,10 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
[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(() => {
|
||||
createNewChat()
|
||||
logger.info('Started new chat')
|
||||
@@ -240,7 +256,10 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
}, 100)
|
||||
}, [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(
|
||||
(value: string) => {
|
||||
setInputValue(value)
|
||||
@@ -251,7 +270,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
[setInputValue]
|
||||
)
|
||||
|
||||
/** Exposes imperative functions to parent */
|
||||
// Expose functions to parent
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
@@ -262,7 +281,10 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
[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(() => {
|
||||
abortMessage()
|
||||
if (showPlanTodos) {
|
||||
@@ -270,20 +292,20 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
}
|
||||
}, [abortMessage, showPlanTodos])
|
||||
|
||||
/** Closes the plan todos section and clears the todos */
|
||||
const handleClosePlanTodos = useCallback(() => {
|
||||
closePlanTodos()
|
||||
setPlanTodos([])
|
||||
}, [closePlanTodos, setPlanTodos])
|
||||
|
||||
/** Handles message submission to the copilot */
|
||||
/**
|
||||
* Handles message submission to the copilot
|
||||
* @param query - The message text to send
|
||||
* @param fileAttachments - Optional file attachments
|
||||
* @param contexts - Optional context references
|
||||
*/
|
||||
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
|
||||
if (!query || !activeWorkflowId) return
|
||||
|
||||
if (showPlanTodos) {
|
||||
setPlanTodos([])
|
||||
const store = useCopilotStore.getState()
|
||||
store.setPlanTodos([])
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -297,25 +319,37 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
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(
|
||||
(messageId: string, isEditing: boolean, cancelCallback?: () => void) => {
|
||||
setEditingMessageId(isEditing ? messageId : null)
|
||||
setIsEditingMessage(isEditing)
|
||||
cancelEditCallbackRef.current = isEditing ? cancelCallback || null : null
|
||||
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) => {
|
||||
setRevertingMessageId(isReverting ? messageId : null)
|
||||
}, [])
|
||||
|
||||
/** Handles chat deletion */
|
||||
/**
|
||||
* Handles chat deletion
|
||||
* @param chatId - ID of the chat to delete
|
||||
*/
|
||||
const handleDeleteChat = useCallback(
|
||||
async (chatId: string) => {
|
||||
try {
|
||||
@@ -327,15 +361,38 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
[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(
|
||||
(open: boolean) => {
|
||||
setIsHistoryDropdownOpen(open)
|
||||
// Fire hook without awaiting - prevents blocking and state issues
|
||||
handleHistoryDropdownOpenHook(open)
|
||||
},
|
||||
[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 (
|
||||
<>
|
||||
<div
|
||||
@@ -474,18 +531,21 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
className='h-full overflow-y-auto overflow-x-hidden px-[8px]'
|
||||
>
|
||||
<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'
|
||||
}`}
|
||||
>
|
||||
{messages.map((message, index) => {
|
||||
// Determine if this message should be dimmed
|
||||
let isDimmed = false
|
||||
|
||||
// Dim messages after the one being edited
|
||||
if (editingMessageId) {
|
||||
const editingIndex = messages.findIndex((m) => m.id === editingMessageId)
|
||||
isDimmed = editingIndex !== -1 && index > editingIndex
|
||||
}
|
||||
|
||||
// Also dim messages after the one showing restore confirmation
|
||||
if (!isDimmed && revertingMessageId) {
|
||||
const revertingIndex = messages.findIndex(
|
||||
(m) => m.id === revertingMessageId
|
||||
@@ -493,6 +553,7 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
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
|
||||
|
||||
return (
|
||||
@@ -527,7 +588,11 @@ export const Copilot = forwardRef<CopilotRef, CopilotProps>(({ panelWidth }, ref
|
||||
<TodoList
|
||||
todos={planTodos}
|
||||
collapsed={todosCollapsed}
|
||||
onClose={handleClosePlanTodos}
|
||||
onClose={() => {
|
||||
const store = useCopilotStore.getState()
|
||||
store.closePlanTodos?.()
|
||||
useCopilotStore.setState({ planTodos: [] })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -24,7 +24,9 @@ export function useChatHistory(props: UseChatHistoryProps) {
|
||||
const { chats, activeWorkflowId, copilotWorkflowId, loadChats, areChatsFresh, isSendingMessage } =
|
||||
props
|
||||
|
||||
/** Groups chats by time period (Today, Yesterday, This Week, etc.) */
|
||||
/**
|
||||
* Groups chats by time period (Today, Yesterday, This Week, etc.)
|
||||
*/
|
||||
const groupedChats = useMemo(() => {
|
||||
if (!activeWorkflowId || copilotWorkflowId !== activeWorkflowId || chats.length === 0) {
|
||||
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)
|
||||
}, [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(
|
||||
(open: boolean) => {
|
||||
// Only load if opening dropdown AND we don't have fresh chats AND not streaming
|
||||
if (open && activeWorkflowId && !isSendingMessage && !areChatsFresh(activeWorkflowId)) {
|
||||
// Fire in background, don't await - same pattern as old panel
|
||||
loadChats(false).catch((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 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(() => {
|
||||
if (activeWorkflowId && !hasMountedRef.current && !isSendingMessage) {
|
||||
hasMountedRef.current = true
|
||||
@@ -46,12 +50,19 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
|
||||
lastWorkflowIdRef.current = null
|
||||
|
||||
setCopilotWorkflowId(activeWorkflowId)
|
||||
// Use false to let the store decide if a reload is needed based on cache
|
||||
loadChats(false)
|
||||
}
|
||||
}, [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(() => {
|
||||
// Handle genuine workflow changes (not initial mount, not same workflow)
|
||||
// Only reload if not currently streaming to avoid interrupting conversations
|
||||
if (
|
||||
activeWorkflowId &&
|
||||
activeWorkflowId !== lastWorkflowIdRef.current &&
|
||||
@@ -69,23 +80,7 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
|
||||
loadChats(false)
|
||||
}
|
||||
|
||||
if (
|
||||
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)
|
||||
}
|
||||
|
||||
// Mark as initialized when chats are loaded for the active workflow
|
||||
if (
|
||||
activeWorkflowId &&
|
||||
!isLoadingChats &&
|
||||
@@ -105,7 +100,9 @@ export function useCopilotInitialization(props: UseCopilotInitializationProps) {
|
||||
isSendingMessage,
|
||||
])
|
||||
|
||||
/** Load auto-allowed tools once on mount */
|
||||
/**
|
||||
* Load auto-allowed tools once on mount
|
||||
*/
|
||||
const hasLoadedAutoAllowedToolsRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (hasMountedRef.current && !hasLoadedAutoAllowedToolsRef.current) {
|
||||
|
||||
@@ -6,6 +6,7 @@ interface UseTodoManagementProps {
|
||||
isSendingMessage: boolean
|
||||
showPlanTodos: boolean
|
||||
planTodos: Array<{ id: string; content: string; completed?: boolean }>
|
||||
setPlanTodos: (todos: any[]) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -15,12 +16,14 @@ interface UseTodoManagementProps {
|
||||
* @returns Todo management utilities
|
||||
*/
|
||||
export function useTodoManagement(props: UseTodoManagementProps) {
|
||||
const { isSendingMessage, showPlanTodos, planTodos } = props
|
||||
const { isSendingMessage, showPlanTodos, planTodos, setPlanTodos } = props
|
||||
|
||||
const [todosCollapsed, setTodosCollapsed] = useState(false)
|
||||
const wasSendingRef = useRef(false)
|
||||
|
||||
/** Auto-collapse todos when stream completes */
|
||||
/**
|
||||
* Auto-collapse todos when stream completes. Do not prune items.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (wasSendingRef.current && !isSendingMessage && showPlanTodos) {
|
||||
setTodosCollapsed(true)
|
||||
@@ -28,7 +31,9 @@ export function useTodoManagement(props: UseTodoManagementProps) {
|
||||
wasSendingRef.current = isSendingMessage
|
||||
}, [isSendingMessage, showPlanTodos])
|
||||
|
||||
/** Reset collapsed state when todos first appear */
|
||||
/**
|
||||
* Reset collapsed state when todos first appear
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (showPlanTodos && planTodos.length > 0) {
|
||||
if (isSendingMessage) {
|
||||
|
||||
@@ -452,6 +452,39 @@ console.log(limits);`
|
||||
</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 className='mb-[6.5px] flex items-center justify-between'>
|
||||
<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'>
|
||||
<ModalHeader>Promote to live</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
Are you sure you want to promote{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>
|
||||
{versionToPromoteInfo?.name || `v${versionToPromote}`}
|
||||
|
||||
@@ -43,7 +43,6 @@ import type { WorkflowState } from '@/stores/workflows/workflow/types'
|
||||
import { A2aDeploy } from './components/a2a/a2a'
|
||||
import { ApiDeploy } from './components/api/api'
|
||||
import { ChatDeploy, type ExistingChat } from './components/chat/chat'
|
||||
import { ApiInfoModal } from './components/general/components/api-info-modal'
|
||||
import { GeneralDeploy } from './components/general/general'
|
||||
import { McpDeploy } from './components/mcp/mcp'
|
||||
import { TemplateDeploy } from './components/template/template'
|
||||
@@ -111,7 +110,6 @@ export function DeployModal({
|
||||
const [chatSuccess, setChatSuccess] = useState(false)
|
||||
|
||||
const [isCreateKeyModalOpen, setIsCreateKeyModalOpen] = useState(false)
|
||||
const [isApiInfoModalOpen, setIsApiInfoModalOpen] = useState(false)
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
const canManageWorkspaceKeys = userPermissions.canAdmin
|
||||
const { config: permissionConfig } = usePermissionConfig()
|
||||
@@ -391,6 +389,11 @@ export function DeployModal({
|
||||
form?.requestSubmit()
|
||||
}, [])
|
||||
|
||||
const handleA2aFormSubmit = useCallback(() => {
|
||||
const form = document.getElementById('a2a-deploy-form') as HTMLFormElement
|
||||
form?.requestSubmit()
|
||||
}, [])
|
||||
|
||||
const handleA2aPublish = useCallback(() => {
|
||||
const form = document.getElementById('a2a-deploy-form')
|
||||
const publishTrigger = form?.querySelector('[data-a2a-publish-trigger]') as HTMLButtonElement
|
||||
@@ -593,9 +596,6 @@ export function DeployModal({
|
||||
<ModalFooter className='items-center justify-between'>
|
||||
<div />
|
||||
<div className='flex items-center gap-2'>
|
||||
<Button variant='default' onClick={() => setIsApiInfoModalOpen(true)}>
|
||||
Edit API Info
|
||||
</Button>
|
||||
<Button
|
||||
variant='tertiary'
|
||||
onClick={() => setIsCreateKeyModalOpen(true)}
|
||||
@@ -880,14 +880,6 @@ export function DeployModal({
|
||||
canManageWorkspaceKeys={canManageWorkspaceKeys}
|
||||
defaultKeyType={defaultKeyType}
|
||||
/>
|
||||
|
||||
{workflowId && (
|
||||
<ApiInfoModal
|
||||
open={isApiInfoModalOpen}
|
||||
onOpenChange={setIsApiInfoModalOpen}
|
||||
workflowId={workflowId}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ export function CodeEditor({
|
||||
placeholder = '',
|
||||
className = '',
|
||||
gutterClassName = '',
|
||||
minHeight,
|
||||
minHeight = '360px',
|
||||
highlightVariables = true,
|
||||
onKeyDown,
|
||||
disabled = false,
|
||||
@@ -186,7 +186,7 @@ export function CodeEditor({
|
||||
}
|
||||
|
||||
return (
|
||||
<Code.Container className={className} style={minHeight ? { minHeight } : undefined}>
|
||||
<Code.Container className={className} style={{ minHeight }}>
|
||||
{showWandButton && onWandClick && (
|
||||
<Button
|
||||
variant='ghost'
|
||||
@@ -220,7 +220,7 @@ export function CodeEditor({
|
||||
disabled={disabled}
|
||||
{...getCodeEditorProps({ disabled })}
|
||||
className={cn(getCodeEditorProps({ disabled }).className, 'h-full')}
|
||||
style={minHeight ? { minHeight } : undefined}
|
||||
style={{ minHeight }}
|
||||
textareaClassName={cn(
|
||||
getCodeEditorProps({ disabled }).textareaClassName,
|
||||
'!block !h-full !min-h-full'
|
||||
|
||||
@@ -87,16 +87,15 @@ export function CustomToolModal({
|
||||
const [codeError, setCodeError] = useState<string | null>(null)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [toolId, setToolId] = useState<string | undefined>(undefined)
|
||||
const [initialJsonSchema, setInitialJsonSchema] = useState('')
|
||||
const [initialFunctionCode, setInitialFunctionCode] = useState('')
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [showDiscardAlert, setShowDiscardAlert] = useState(false)
|
||||
const [isSchemaPromptActive, setIsSchemaPromptActive] = useState(false)
|
||||
const [schemaPromptInput, setSchemaPromptInput] = useState('')
|
||||
const [schemaPromptSummary, setSchemaPromptSummary] = useState<string | null>(null)
|
||||
const schemaPromptInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const [isCodePromptActive, setIsCodePromptActive] = useState(false)
|
||||
const [codePromptInput, setCodePromptInput] = useState('')
|
||||
const [codePromptSummary, setCodePromptSummary] = useState<string | null>(null)
|
||||
const codePromptInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const schemaGeneration = useWand({
|
||||
@@ -175,9 +174,6 @@ Example 2:
|
||||
generationType: 'custom-tool-schema',
|
||||
},
|
||||
currentValue: jsonSchema,
|
||||
onStreamStart: () => {
|
||||
setJsonSchema('')
|
||||
},
|
||||
onGeneratedContent: (content) => {
|
||||
setJsonSchema(content)
|
||||
setSchemaError(null)
|
||||
@@ -241,9 +237,6 @@ try {
|
||||
generationType: 'javascript-function-body',
|
||||
},
|
||||
currentValue: functionCode,
|
||||
onStreamStart: () => {
|
||||
setFunctionCode('')
|
||||
},
|
||||
onGeneratedContent: (content) => {
|
||||
handleFunctionCodeChange(content)
|
||||
setCodeError(null)
|
||||
@@ -279,15 +272,12 @@ try {
|
||||
|
||||
if (initialValues) {
|
||||
try {
|
||||
const schemaValue =
|
||||
setJsonSchema(
|
||||
typeof initialValues.schema === 'string'
|
||||
? initialValues.schema
|
||||
: JSON.stringify(initialValues.schema, null, 2)
|
||||
const codeValue = initialValues.code || ''
|
||||
setJsonSchema(schemaValue)
|
||||
setFunctionCode(codeValue)
|
||||
setInitialJsonSchema(schemaValue)
|
||||
setInitialFunctionCode(codeValue)
|
||||
)
|
||||
setFunctionCode(initialValues.code || '')
|
||||
setIsEditing(true)
|
||||
setToolId(initialValues.id)
|
||||
} catch (error) {
|
||||
@@ -314,18 +304,17 @@ try {
|
||||
const resetForm = () => {
|
||||
setJsonSchema('')
|
||||
setFunctionCode('')
|
||||
setInitialJsonSchema('')
|
||||
setInitialFunctionCode('')
|
||||
setSchemaError(null)
|
||||
setCodeError(null)
|
||||
setActiveSection('schema')
|
||||
setIsEditing(false)
|
||||
setToolId(undefined)
|
||||
setSchemaPromptSummary(null)
|
||||
setCodePromptSummary(null)
|
||||
setIsSchemaPromptActive(false)
|
||||
setIsCodePromptActive(false)
|
||||
setSchemaPromptInput('')
|
||||
setCodePromptInput('')
|
||||
setShowDiscardAlert(false)
|
||||
schemaGeneration.closePrompt()
|
||||
schemaGeneration.hidePromptInline()
|
||||
codeGeneration.closePrompt()
|
||||
@@ -339,37 +328,31 @@ try {
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
const validateSchema = (schema: string): { isValid: boolean; error: string | null } => {
|
||||
if (!schema) return { isValid: false, error: null }
|
||||
const validateJsonSchema = (schema: string): boolean => {
|
||||
if (!schema) return false
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(schema)
|
||||
|
||||
if (!parsed.type || parsed.type !== 'function') {
|
||||
return { isValid: false, error: 'Missing "type": "function"' }
|
||||
}
|
||||
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 false
|
||||
}
|
||||
|
||||
return { isValid: true, error: null }
|
||||
} catch {
|
||||
return { isValid: false, error: 'Invalid JSON format' }
|
||||
if (!parsed.function || !parsed.function.name) {
|
||||
return false
|
||||
}
|
||||
|
||||
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])
|
||||
|
||||
const isSchemaValid = useMemo(() => validateSchema(jsonSchema).isValid, [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 isSchemaValid = useMemo(() => validateJsonSchema(jsonSchema), [jsonSchema])
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
@@ -426,9 +384,43 @@ try {
|
||||
return
|
||||
}
|
||||
|
||||
const { isValid, error } = validateSchema(jsonSchema)
|
||||
if (!isValid) {
|
||||
setSchemaError(error)
|
||||
const parsed = JSON.parse(jsonSchema)
|
||||
|
||||
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')
|
||||
return
|
||||
}
|
||||
@@ -491,9 +483,17 @@ try {
|
||||
}
|
||||
|
||||
onSave(customTool)
|
||||
|
||||
setSchemaPromptSummary(null)
|
||||
setCodePromptSummary(null)
|
||||
|
||||
handleClose()
|
||||
} catch (error) {
|
||||
logger.error('Error saving custom tool:', { error })
|
||||
|
||||
setSchemaPromptSummary(null)
|
||||
setCodePromptSummary(null)
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to save custom tool'
|
||||
|
||||
if (errorMessage.includes('Cannot change function name')) {
|
||||
@@ -512,8 +512,46 @@ try {
|
||||
setJsonSchema(value)
|
||||
|
||||
if (value.trim()) {
|
||||
const { error } = validateSchema(value)
|
||||
setSchemaError(error)
|
||||
try {
|
||||
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 {
|
||||
setSchemaError(null)
|
||||
}
|
||||
@@ -671,12 +709,12 @@ try {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setSchemaParamSelectedIndex((prev) => Math.min(prev + 1, schemaParameters.length - 1))
|
||||
return
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setSchemaParamSelectedIndex((prev) => Math.max(prev - 1, 0))
|
||||
return
|
||||
break
|
||||
case 'Enter':
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
@@ -684,17 +722,14 @@ try {
|
||||
const selectedParam = schemaParameters[schemaParamSelectedIndex]
|
||||
handleSchemaParamSelect(selectedParam.name)
|
||||
}
|
||||
return
|
||||
break
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setShowSchemaParams(false)
|
||||
return
|
||||
case ' ':
|
||||
case 'Tab':
|
||||
setShowSchemaParams(false)
|
||||
return
|
||||
break
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (showEnvVars || showTags) {
|
||||
@@ -708,7 +743,7 @@ try {
|
||||
const handleSchemaWandClick = () => {
|
||||
if (schemaGeneration.isLoading || schemaGeneration.isStreaming) return
|
||||
setIsSchemaPromptActive(true)
|
||||
setSchemaPromptInput('')
|
||||
setSchemaPromptInput(schemaPromptSummary ?? '')
|
||||
setTimeout(() => {
|
||||
schemaPromptInputRef.current?.focus()
|
||||
}, 0)
|
||||
@@ -727,6 +762,7 @@ try {
|
||||
const handleSchemaPromptSubmit = () => {
|
||||
const trimmedPrompt = schemaPromptInput.trim()
|
||||
if (!trimmedPrompt || schemaGeneration.isLoading || schemaGeneration.isStreaming) return
|
||||
setSchemaPromptSummary(trimmedPrompt)
|
||||
schemaGeneration.generateStream({ prompt: trimmedPrompt })
|
||||
setSchemaPromptInput('')
|
||||
setIsSchemaPromptActive(false)
|
||||
@@ -746,7 +782,7 @@ try {
|
||||
const handleCodeWandClick = () => {
|
||||
if (codeGeneration.isLoading || codeGeneration.isStreaming) return
|
||||
setIsCodePromptActive(true)
|
||||
setCodePromptInput('')
|
||||
setCodePromptInput(codePromptSummary ?? '')
|
||||
setTimeout(() => {
|
||||
codePromptInputRef.current?.focus()
|
||||
}, 0)
|
||||
@@ -765,6 +801,7 @@ try {
|
||||
const handleCodePromptSubmit = () => {
|
||||
const trimmedPrompt = codePromptInput.trim()
|
||||
if (!trimmedPrompt || codeGeneration.isLoading || codeGeneration.isStreaming) return
|
||||
setCodePromptSummary(trimmedPrompt)
|
||||
codeGeneration.generateStream({ prompt: trimmedPrompt })
|
||||
setCodePromptInput('')
|
||||
setIsCodePromptActive(false)
|
||||
@@ -809,8 +846,19 @@ try {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal open={open} onOpenChange={handleCloseAttempt}>
|
||||
<ModalContent size='xl'>
|
||||
<Modal open={open} onOpenChange={handleClose}>
|
||||
<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>
|
||||
|
||||
<ModalTabs
|
||||
@@ -1163,7 +1211,7 @@ try {
|
||||
<Button
|
||||
variant='tertiary'
|
||||
onClick={handleSave}
|
||||
disabled={!isSchemaValid || !!schemaError || !hasChanges}
|
||||
disabled={!isSchemaValid || !!schemaError}
|
||||
>
|
||||
{isEditing ? 'Update Tool' : 'Save Tool'}
|
||||
</Button>
|
||||
@@ -1200,26 +1248,6 @@ try {
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</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}
|
||||
showGutter
|
||||
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}
|
||||
gutterStyle={{ backgroundColor: 'transparent' }}
|
||||
wrapText={wrapText}
|
||||
@@ -624,7 +624,7 @@ const OutputPanel = React.memo(function OutputPanel({
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
<Popover open={outputOptionsOpen} onOpenChange={setOutputOptionsOpen} size='sm'>
|
||||
<Popover open={outputOptionsOpen} onOpenChange={setOutputOptionsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
@@ -648,7 +648,7 @@ const OutputPanel = React.memo(function OutputPanel({
|
||||
>
|
||||
<PopoverItem
|
||||
active={wrapText}
|
||||
showCheck={wrapText}
|
||||
showCheck
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setWrapText(!wrapText)
|
||||
@@ -658,7 +658,7 @@ const OutputPanel = React.memo(function OutputPanel({
|
||||
</PopoverItem>
|
||||
<PopoverItem
|
||||
active={openOnRun}
|
||||
showCheck={openOnRun}
|
||||
showCheck
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setOpenOnRun(!openOnRun)
|
||||
@@ -1472,7 +1472,7 @@ export const Terminal = memo(function Terminal() {
|
||||
>
|
||||
{uniqueBlocks.length > 0 ? (
|
||||
<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>
|
||||
<Button
|
||||
variant='ghost'
|
||||
@@ -1508,12 +1508,12 @@ export const Terminal = memo(function Terminal() {
|
||||
<PopoverItem
|
||||
key={block.blockId}
|
||||
active={isSelected}
|
||||
showCheck={isSelected}
|
||||
onClick={() => toggleBlock(block.blockId)}
|
||||
className={index > 0 ? 'mt-[2px]' : ''}
|
||||
>
|
||||
{BlockIcon && <BlockIcon className='h-3 w-3' />}
|
||||
<span className='flex-1'>{block.blockName}</span>
|
||||
{isSelected && <Check className='h-3 w-3' />}
|
||||
</PopoverItem>
|
||||
)
|
||||
})}
|
||||
@@ -1526,7 +1526,7 @@ export const Terminal = memo(function Terminal() {
|
||||
)}
|
||||
{hasStatusEntries ? (
|
||||
<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>
|
||||
<Button
|
||||
variant='ghost'
|
||||
@@ -1555,7 +1555,6 @@ export const Terminal = memo(function Terminal() {
|
||||
<PopoverScrollArea style={{ maxHeight: '140px' }}>
|
||||
<PopoverItem
|
||||
active={filters.statuses.has('error')}
|
||||
showCheck={filters.statuses.has('error')}
|
||||
onClick={() => toggleStatus('error')}
|
||||
>
|
||||
<div
|
||||
@@ -1563,10 +1562,10 @@ export const Terminal = memo(function Terminal() {
|
||||
style={{ backgroundColor: 'var(--text-error)' }}
|
||||
/>
|
||||
<span className='flex-1'>Error</span>
|
||||
{filters.statuses.has('error') && <Check className='h-3 w-3' />}
|
||||
</PopoverItem>
|
||||
<PopoverItem
|
||||
active={filters.statuses.has('info')}
|
||||
showCheck={filters.statuses.has('info')}
|
||||
onClick={() => toggleStatus('info')}
|
||||
className='mt-[2px]'
|
||||
>
|
||||
@@ -1575,6 +1574,7 @@ export const Terminal = memo(function Terminal() {
|
||||
style={{ backgroundColor: 'var(--terminal-status-info-color)' }}
|
||||
/>
|
||||
<span className='flex-1'>Info</span>
|
||||
{filters.statuses.has('info') && <Check className='h-3 w-3' />}
|
||||
</PopoverItem>
|
||||
</PopoverScrollArea>
|
||||
</PopoverContent>
|
||||
@@ -1585,7 +1585,7 @@ export const Terminal = memo(function Terminal() {
|
||||
)}
|
||||
{uniqueRunIds.length > 0 ? (
|
||||
<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>
|
||||
<Button
|
||||
variant='ghost'
|
||||
@@ -1620,16 +1620,16 @@ export const Terminal = memo(function Terminal() {
|
||||
<PopoverItem
|
||||
key={runId}
|
||||
active={isSelected}
|
||||
showCheck={isSelected}
|
||||
onClick={() => toggleRunId(runId)}
|
||||
className={index > 0 ? 'mt-[2px]' : ''}
|
||||
>
|
||||
<span
|
||||
className='flex-1 font-mono text-[11px]'
|
||||
className='flex-1 font-mono text-[12px]'
|
||||
style={{ color: runIdColor || '#D2D2D2' }}
|
||||
>
|
||||
{formatRunId(runId)}
|
||||
</span>
|
||||
{isSelected && <Check className='h-3 w-3' />}
|
||||
</PopoverItem>
|
||||
)
|
||||
})}
|
||||
@@ -1765,7 +1765,7 @@ export const Terminal = memo(function Terminal() {
|
||||
</Tooltip.Root>
|
||||
</>
|
||||
)}
|
||||
<Popover open={mainOptionsOpen} onOpenChange={setMainOptionsOpen} size='sm'>
|
||||
<Popover open={mainOptionsOpen} onOpenChange={setMainOptionsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
@@ -1789,7 +1789,7 @@ export const Terminal = memo(function Terminal() {
|
||||
>
|
||||
<PopoverItem
|
||||
active={openOnRun}
|
||||
showCheck={openOnRun}
|
||||
showCheck
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setOpenOnRun(!openOnRun)
|
||||
|
||||
@@ -31,11 +31,9 @@ import {
|
||||
} from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-block/utils'
|
||||
import { useBlockVisual } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks'
|
||||
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 { getDependsOnFields } from '@/blocks/utils'
|
||||
import { useKnowledgeBase } from '@/hooks/kb/use-knowledge'
|
||||
import { useCustomTools } from '@/hooks/queries/custom-tools'
|
||||
import { useMcpServers, useMcpToolsQuery } from '@/hooks/queries/mcp'
|
||||
import { useCredentialName } from '@/hooks/queries/oauth-credentials'
|
||||
import { useReactivateSchedule, useScheduleInfo } from '@/hooks/queries/schedules'
|
||||
@@ -563,59 +561,6 @@ const SubBlockRow = memo(function SubBlockRow({
|
||||
return `${names[0]}, ${names[1]} +${names.length - 2}`
|
||||
}, [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 maskedValue = isPasswordField && value && value !== '-' ? '•••' : null
|
||||
|
||||
@@ -624,7 +569,6 @@ const SubBlockRow = memo(function SubBlockRow({
|
||||
credentialName ||
|
||||
dropdownLabel ||
|
||||
variablesDisplayValue ||
|
||||
toolsDisplayValue ||
|
||||
knowledgeBaseDisplayName ||
|
||||
workflowSelectionName ||
|
||||
mcpServerDisplayName ||
|
||||
|
||||
@@ -3,20 +3,19 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Options for configuring scroll behavior
|
||||
* Options for configuring scroll behavior.
|
||||
*/
|
||||
interface UseScrollManagementOptions {
|
||||
/**
|
||||
* Scroll behavior for programmatic scrolls
|
||||
* @remarks
|
||||
* - `smooth`: Animated scroll (default, used by Copilot)
|
||||
* - `auto`: Immediate scroll to bottom (used by floating chat to avoid jitter)
|
||||
* Scroll behavior for programmatic scrolls.
|
||||
* - `smooth`: animated scroll (default, used by Copilot).
|
||||
* - `auto`: immediate scroll to bottom (used by floating chat to avoid jitter).
|
||||
*/
|
||||
behavior?: 'auto' | 'smooth'
|
||||
/**
|
||||
* Distance from bottom (in pixels) within which auto-scroll stays active
|
||||
* @remarks Lower values = less sticky (user can scroll away easier)
|
||||
* @defaultValue 100
|
||||
* Distance from bottom (in pixels) within which auto-scroll stays active.
|
||||
* Lower values = less sticky (user can scroll away easier).
|
||||
* Default is 100px.
|
||||
*/
|
||||
stickinessThreshold?: number
|
||||
}
|
||||
@@ -36,105 +35,166 @@ export function useScrollManagement(
|
||||
options?: UseScrollManagementOptions
|
||||
) {
|
||||
const scrollAreaRef = useRef<HTMLDivElement>(null)
|
||||
const [userHasScrolledAway, setUserHasScrolledAway] = useState(false)
|
||||
const programmaticScrollRef = useRef(false)
|
||||
const [isNearBottom, setIsNearBottom] = useState(true)
|
||||
const [userHasScrolledDuringStream, setUserHasScrolledDuringStream] = useState(false)
|
||||
const programmaticScrollInProgressRef = useRef(false)
|
||||
const lastScrollTopRef = useRef(0)
|
||||
|
||||
const scrollBehavior = options?.behavior ?? 'smooth'
|
||||
const scrollBehavior: 'auto' | 'smooth' = options?.behavior ?? 'smooth'
|
||||
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 container = scrollAreaRef.current
|
||||
if (!container) return
|
||||
|
||||
programmaticScrollRef.current = true
|
||||
container.scrollTo({ top: container.scrollHeight, behavior: scrollBehavior })
|
||||
const scrollContainer = getScrollContainer()
|
||||
if (!scrollContainer) return
|
||||
|
||||
programmaticScrollInProgressRef.current = true
|
||||
scrollContainer.scrollTo({
|
||||
top: scrollContainer.scrollHeight,
|
||||
behavior: scrollBehavior,
|
||||
})
|
||||
// Best-effort reset; not all browsers fire scrollend reliably
|
||||
window.setTimeout(() => {
|
||||
programmaticScrollRef.current = false
|
||||
programmaticScrollInProgressRef.current = false
|
||||
}, 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 container = scrollAreaRef.current
|
||||
if (!container || programmaticScrollRef.current) return
|
||||
const scrollContainer = getScrollContainer()
|
||||
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 nearBottom = distanceFromBottom <= stickinessThreshold
|
||||
const delta = scrollTop - lastScrollTopRef.current
|
||||
setIsNearBottom(nearBottom)
|
||||
|
||||
if (isSendingMessage) {
|
||||
// User scrolled up during streaming - break away
|
||||
if (delta < -2) {
|
||||
setUserHasScrolledAway(true)
|
||||
const delta = scrollTop - lastScrollTopRef.current
|
||||
const movedUp = delta < -2 // small hysteresis to avoid noise
|
||||
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) {
|
||||
setUserHasScrolledAway(false)
|
||||
|
||||
// If the user has broken away and scrolls back down to the bottom, re-stick
|
||||
if (userHasScrolledDuringStream && movedDown && nearBottom) {
|
||||
setUserHasScrolledDuringStream(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Track last scrollTop for direction detection
|
||||
lastScrollTopRef.current = scrollTop
|
||||
}, [isSendingMessage, userHasScrolledAway, stickinessThreshold])
|
||||
}, [getScrollContainer, isSendingMessage, userHasScrolledDuringStream, stickinessThreshold])
|
||||
|
||||
/** Attaches scroll listener to container */
|
||||
// Attach scroll listener
|
||||
useEffect(() => {
|
||||
const container = scrollAreaRef.current
|
||||
if (!container) return
|
||||
const scrollContainer = getScrollContainer()
|
||||
if (!scrollContainer) return
|
||||
|
||||
container.addEventListener('scroll', handleScroll, { passive: true })
|
||||
lastScrollTopRef.current = container.scrollTop
|
||||
const handleUserScroll = () => {
|
||||
handleScroll()
|
||||
}
|
||||
|
||||
return () => container.removeEventListener('scroll', handleScroll)
|
||||
}, [handleScroll])
|
||||
scrollContainer.addEventListener('scroll', handleUserScroll, { passive: true })
|
||||
|
||||
/** 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(() => {
|
||||
if (messages.length === 0) return
|
||||
|
||||
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
|
||||
if (isUserMessage) {
|
||||
setUserHasScrolledAway(false)
|
||||
scrollToBottom()
|
||||
} else if (!userHasScrolledAway) {
|
||||
const shouldAutoScroll =
|
||||
isNewUserMessage ||
|
||||
(isSendingMessage && !userHasScrolledDuringStream) ||
|
||||
(!isSendingMessage && isNearBottom)
|
||||
|
||||
if (shouldAutoScroll) {
|
||||
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(() => {
|
||||
if (!isSendingMessage) {
|
||||
setUserHasScrolledAway(false)
|
||||
const lastMessage = messages[messages.length - 1]
|
||||
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])
|
||||
|
||||
/** Keeps scroll pinned during streaming - uses interval, stops when user scrolls away */
|
||||
// While streaming and not broken away, keep pinned to bottom
|
||||
useEffect(() => {
|
||||
// Early return stops the interval when user scrolls away (state change re-runs effect)
|
||||
if (!isSendingMessage || userHasScrolledAway) {
|
||||
return
|
||||
}
|
||||
if (!isSendingMessage || userHasScrolledDuringStream) return
|
||||
|
||||
const intervalId = window.setInterval(() => {
|
||||
const container = scrollAreaRef.current
|
||||
if (!container) return
|
||||
const scrollContainer = getScrollContainer()
|
||||
if (!scrollContainer) return
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = container
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollContainer
|
||||
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
|
||||
|
||||
if (distanceFromBottom > 1) {
|
||||
const nearBottom = distanceFromBottom <= stickinessThreshold
|
||||
if (nearBottom) {
|
||||
scrollToBottom()
|
||||
}
|
||||
}, 100)
|
||||
|
||||
return () => window.clearInterval(intervalId)
|
||||
}, [isSendingMessage, userHasScrolledAway, scrollToBottom])
|
||||
}, [
|
||||
isSendingMessage,
|
||||
userHasScrolledDuringStream,
|
||||
getScrollContainer,
|
||||
scrollToBottom,
|
||||
stickinessThreshold,
|
||||
])
|
||||
|
||||
return {
|
||||
scrollAreaRef,
|
||||
|
||||
@@ -1072,7 +1072,7 @@ export function AccessControl() {
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Unsaved Changes</ModalHeader>
|
||||
<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?
|
||||
</p>
|
||||
</ModalBody>
|
||||
|
||||
@@ -115,7 +115,7 @@ export function CreateApiKeyModal({
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Create new API key</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
{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 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'>
|
||||
<ModalHeader>Your API key has been created</ModalHeader>
|
||||
<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.{' '}
|
||||
<span className='font-semibold text-[var(--text-primary)]'>
|
||||
Copy it now and store it securely.
|
||||
|
||||
@@ -222,7 +222,7 @@ export function BYOK() {
|
||||
)}
|
||||
</ModalHeader>
|
||||
<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}{' '}
|
||||
requests in this workspace. Your key is encrypted and stored securely.
|
||||
</p>
|
||||
@@ -308,7 +308,7 @@ export function BYOK() {
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Delete API Key</ModalHeader>
|
||||
<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{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>
|
||||
{PROVIDERS.find((p) => p.id === deleteConfirmProvider)?.name}
|
||||
|
||||
@@ -214,7 +214,7 @@ export function Copilot() {
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Create new API key</ModalHeader>
|
||||
<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
|
||||
you won't be able to see it again.
|
||||
</p>
|
||||
@@ -276,7 +276,7 @@ export function Copilot() {
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Your API key has been created</ModalHeader>
|
||||
<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.{' '}
|
||||
<span className='font-semibold text-[var(--text-primary)]'>
|
||||
Copy it now and store it securely.
|
||||
|
||||
@@ -824,7 +824,7 @@ export function EnvironmentVariables({ registerBeforeLeaveHandler }: Environment
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Unsaved Changes</ModalHeader>
|
||||
<ModalBody>
|
||||
<p className='text-[12px] text-[var(--text-secondary)]'>
|
||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||
{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. Do you want to save them before closing?'}
|
||||
|
||||
@@ -603,7 +603,7 @@ export function General({ onOpenChange }: GeneralProps) {
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>Reset Password</ModalHeader>
|
||||
<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{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>{profile?.email}</span>.
|
||||
Click the link in the email to create a new password.
|
||||
|
||||
@@ -64,7 +64,7 @@ export function TeamSeats({
|
||||
<ModalContent size='sm'>
|
||||
<ModalHeader>{title}</ModalHeader>
|
||||
<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]'>
|
||||
<Label htmlFor='seats' className='text-[12px]'>
|
||||
|
||||
@@ -25,11 +25,9 @@ const GRID_COLUMNS = 6
|
||||
function ColorGrid({
|
||||
hexInput,
|
||||
setHexInput,
|
||||
onColorChange,
|
||||
}: {
|
||||
hexInput: string
|
||||
setHexInput: (color: string) => void
|
||||
onColorChange?: (color: string) => void
|
||||
}) {
|
||||
const { isInFolder } = usePopoverContext()
|
||||
const [focusedIndex, setFocusedIndex] = useState(-1)
|
||||
@@ -74,9 +72,7 @@ function ColorGrid({
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setHexInput(WORKFLOW_COLORS[index].color)
|
||||
onColorChange?.(WORKFLOW_COLORS[index].color)
|
||||
return
|
||||
default:
|
||||
return
|
||||
@@ -87,7 +83,7 @@ function ColorGrid({
|
||||
buttonRefs.current[newIndex]?.focus()
|
||||
}
|
||||
},
|
||||
[setHexInput, onColorChange]
|
||||
[setHexInput]
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -109,10 +105,8 @@ function ColorGrid({
|
||||
onKeyDown={(e) => handleKeyDown(e, index)}
|
||||
onFocus={() => setFocusedIndex(index)}
|
||||
className={cn(
|
||||
'h-[20px] w-[20px] rounded-[4px] outline-none ring-white ring-offset-0',
|
||||
(focusedIndex === index ||
|
||||
(focusedIndex === -1 && hexInput.toLowerCase() === color.toLowerCase())) &&
|
||||
'ring-[1.5px]'
|
||||
'h-[20px] w-[20px] rounded-[4px] focus:outline-none focus:ring-2 focus:ring-white focus:ring-offset-1 focus:ring-offset-[#1b1b1b]',
|
||||
hexInput.toLowerCase() === color.toLowerCase() && 'ring-1 ring-white'
|
||||
)}
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
@@ -456,11 +450,7 @@ export function ContextMenu({
|
||||
>
|
||||
<div className='flex w-[140px] flex-col gap-[8px] p-[2px]'>
|
||||
{/* Preset colors with keyboard navigation */}
|
||||
<ColorGrid
|
||||
hexInput={hexInput}
|
||||
setHexInput={setHexInput}
|
||||
onColorChange={onColorChange}
|
||||
/>
|
||||
<ColorGrid hexInput={hexInput} setHexInput={setHexInput} />
|
||||
|
||||
{/* Hex input */}
|
||||
<div className='flex items-center gap-[4px]'>
|
||||
|
||||
@@ -459,7 +459,6 @@ export function WorkspaceHeader({
|
||||
value={editingName}
|
||||
onChange={(e) => setEditingName(e.target.value)}
|
||||
onKeyDown={async (e) => {
|
||||
e.stopPropagation()
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
setIsListRenaming(true)
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
/**
|
||||
* Code editor syntax token theme.
|
||||
* Cursor/VS Code base colors with Sim's vibrant saturation.
|
||||
* Colors aligned to Sim brand where applicable.
|
||||
* Light mode: Vibrant colors matching dark mode's aesthetic quality.
|
||||
* Dark mode: VSCode Dark+ inspired colors with deep, vibrant palette.
|
||||
* 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.block-comment,
|
||||
.code-editor-theme .token.prolog,
|
||||
.code-editor-theme .token.doctype,
|
||||
.code-editor-theme .token.cdata {
|
||||
color: #16a34a !important;
|
||||
color: #2e7d32 !important;
|
||||
}
|
||||
|
||||
.code-editor-theme .token.punctuation {
|
||||
@@ -30,7 +30,7 @@
|
||||
.code-editor-theme .token.boolean,
|
||||
.code-editor-theme .token.number,
|
||||
.code-editor-theme .token.constant {
|
||||
color: #16a34a !important;
|
||||
color: #b45309 !important;
|
||||
}
|
||||
|
||||
.code-editor-theme .token.string,
|
||||
@@ -49,7 +49,7 @@
|
||||
.code-editor-theme .token.atrule,
|
||||
.code-editor-theme .token.attr-value,
|
||||
.code-editor-theme .token.keyword {
|
||||
color: #2563eb !important;
|
||||
color: #9333ea !important;
|
||||
}
|
||||
|
||||
.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.block-comment,
|
||||
.dark .code-editor-theme .token.prolog,
|
||||
.dark .code-editor-theme .token.doctype,
|
||||
.dark .code-editor-theme .token.cdata {
|
||||
color: #6ec97d !important;
|
||||
color: #8bc985 !important;
|
||||
}
|
||||
|
||||
.dark .code-editor-theme .token.punctuation {
|
||||
color: #d4d4d4 !important;
|
||||
color: #eeeeee !important;
|
||||
}
|
||||
|
||||
.dark .code-editor-theme .token.property,
|
||||
.dark .code-editor-theme .token.attr-name,
|
||||
.dark .code-editor-theme .token.variable {
|
||||
color: #4fc3f7 !important;
|
||||
color: #5fc9cb !important;
|
||||
}
|
||||
|
||||
.dark .code-editor-theme .token.tag,
|
||||
.dark .code-editor-theme .token.boolean,
|
||||
.dark .code-editor-theme .token.number,
|
||||
.dark .code-editor-theme .token.constant {
|
||||
color: #a5d6a7 !important;
|
||||
color: #ffc857 !important;
|
||||
}
|
||||
|
||||
.dark .code-editor-theme .token.string,
|
||||
.dark .code-editor-theme .token.char,
|
||||
.dark .code-editor-theme .token.builtin,
|
||||
.dark .code-editor-theme .token.inserted {
|
||||
color: #f39c6b !important;
|
||||
color: #ff6b6b !important;
|
||||
}
|
||||
|
||||
.dark .code-editor-theme .token.operator,
|
||||
.dark .code-editor-theme .token.entity,
|
||||
.dark .code-editor-theme .token.url {
|
||||
color: #d4d4d4 !important;
|
||||
color: #eeeeee !important;
|
||||
}
|
||||
|
||||
.dark .code-editor-theme .token.atrule,
|
||||
.dark .code-editor-theme .token.attr-value,
|
||||
.dark .code-editor-theme .token.keyword {
|
||||
color: #4db8ff !important;
|
||||
color: #d896d8 !important;
|
||||
}
|
||||
|
||||
.dark .code-editor-theme .token.function,
|
||||
.dark .code-editor-theme .token.class-name {
|
||||
color: #fbbf24 !important;
|
||||
color: #ffc857 !important;
|
||||
}
|
||||
|
||||
.dark .code-editor-theme .token.regex,
|
||||
.dark .code-editor-theme .token.important {
|
||||
color: #f87171 !important;
|
||||
color: #ff6b6b !important;
|
||||
}
|
||||
|
||||
.dark .code-editor-theme .token.symbol {
|
||||
color: #d4d4d4 !important;
|
||||
color: #eeeeee !important;
|
||||
}
|
||||
|
||||
.dark .code-editor-theme .token.deleted {
|
||||
color: #f87171 !important;
|
||||
color: #ff6b6b !important;
|
||||
}
|
||||
|
||||
/* Blue accents for <var> and {{ENV}} placeholders - dark mode */
|
||||
|
||||
@@ -460,13 +460,6 @@ const PopoverContent = React.forwardRef<
|
||||
const content = contentRef.current
|
||||
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>(
|
||||
'[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 {
|
||||
ChunkData,
|
||||
ChunksPagination,
|
||||
@@ -332,628 +332,3 @@ export function useDocumentChunkSearchQuery(
|
||||
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) => {
|
||||
if (params?.query && typeof params.query === 'string') {
|
||||
const query = params.query
|
||||
const truncated = query.length > 40 ? `${query.slice(0, 40)}...` : query
|
||||
|
||||
switch (state) {
|
||||
case ClientToolCallState.success:
|
||||
return `Found examples for ${query}`
|
||||
return `Found examples for ${truncated}`
|
||||
case ClientToolCallState.executing:
|
||||
case ClientToolCallState.generating:
|
||||
case ClientToolCallState.pending:
|
||||
return `Searching examples for ${query}`
|
||||
return `Searching examples for ${truncated}`
|
||||
case ClientToolCallState.error:
|
||||
return `Failed to find examples for ${query}`
|
||||
return `Failed to find examples for ${truncated}`
|
||||
case ClientToolCallState.aborted:
|
||||
return `Aborted searching examples for ${query}`
|
||||
return `Aborted searching examples for ${truncated}`
|
||||
case ClientToolCallState.rejected:
|
||||
return `Skipped searching examples for ${query}`
|
||||
return `Skipped searching examples for ${truncated}`
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
|
||||
@@ -32,20 +32,21 @@ export class GetOperationsExamplesClientTool extends BaseClientTool {
|
||||
getDynamicText: (params, state) => {
|
||||
if (params?.query && typeof params.query === 'string') {
|
||||
const query = params.query
|
||||
const truncated = query.length > 40 ? `${query.slice(0, 40)}...` : query
|
||||
|
||||
switch (state) {
|
||||
case ClientToolCallState.success:
|
||||
return `Designed ${query}`
|
||||
return `Designed ${truncated}`
|
||||
case ClientToolCallState.executing:
|
||||
case ClientToolCallState.generating:
|
||||
case ClientToolCallState.pending:
|
||||
return `Designing ${query}`
|
||||
return `Designing ${truncated}`
|
||||
case ClientToolCallState.error:
|
||||
return `Failed to design ${query}`
|
||||
return `Failed to design ${truncated}`
|
||||
case ClientToolCallState.aborted:
|
||||
return `Aborted designing ${query}`
|
||||
return `Aborted designing ${truncated}`
|
||||
case ClientToolCallState.rejected:
|
||||
return `Skipped designing ${query}`
|
||||
return `Skipped designing ${truncated}`
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
|
||||
@@ -26,20 +26,21 @@ export class CrawlWebsiteClientTool extends BaseClientTool {
|
||||
getDynamicText: (params, state) => {
|
||||
if (params?.url && typeof params.url === 'string') {
|
||||
const url = params.url
|
||||
const truncated = url.length > 50 ? `${url.slice(0, 50)}...` : url
|
||||
|
||||
switch (state) {
|
||||
case ClientToolCallState.success:
|
||||
return `Crawled ${url}`
|
||||
return `Crawled ${truncated}`
|
||||
case ClientToolCallState.executing:
|
||||
case ClientToolCallState.generating:
|
||||
case ClientToolCallState.pending:
|
||||
return `Crawling ${url}`
|
||||
return `Crawling ${truncated}`
|
||||
case ClientToolCallState.error:
|
||||
return `Failed to crawl ${url}`
|
||||
return `Failed to crawl ${truncated}`
|
||||
case ClientToolCallState.aborted:
|
||||
return `Aborted crawling ${url}`
|
||||
return `Aborted crawling ${truncated}`
|
||||
case ClientToolCallState.rejected:
|
||||
return `Skipped crawling ${url}`
|
||||
return `Skipped crawling ${truncated}`
|
||||
}
|
||||
}
|
||||
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