mirror of
https://github.com/simstudioai/sim.git
synced 2026-01-08 14:43:54 -05:00
feat(ui): logs, kb, emcn (#2207)
* feat(kb): emcn alignment; sidebar: popover primary; settings-modal: expand * feat: EMCN breadcrumb; improvement(KB): UI * fix: hydration error * improvement(KB): UI * feat: emcn modal sizing, KB tags; refactor: deleted old sidebar * feat(logs): UI * fix: add documents modal name * feat: logs, emcn, cursorrules; refactor: logs * feat: dashboard * feat: notifications; improvement: logs details * fixed random rectangle on canvas * fixed the name of the file to align * fix build --------- Co-authored-by: waleed <walif6@gmail.com>
This commit is contained in:
45
.cursor/rules/emcn-components.mdc
Normal file
45
.cursor/rules/emcn-components.mdc
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
description: EMCN component library patterns with CVA
|
||||||
|
globs: ["apps/sim/components/emcn/**"]
|
||||||
|
---
|
||||||
|
|
||||||
|
# EMCN Component Guidelines
|
||||||
|
|
||||||
|
## When to Use CVA vs Direct Styles
|
||||||
|
|
||||||
|
**Use CVA (class-variance-authority) when:**
|
||||||
|
- 2+ visual variants (primary, secondary, outline)
|
||||||
|
- Multiple sizes or state variations
|
||||||
|
- Example: Button with variants
|
||||||
|
|
||||||
|
**Use direct className when:**
|
||||||
|
- Single consistent style
|
||||||
|
- No variations needed
|
||||||
|
- Example: Label with one style
|
||||||
|
|
||||||
|
## Patterns
|
||||||
|
|
||||||
|
**With CVA:**
|
||||||
|
```tsx
|
||||||
|
const buttonVariants = cva('base-classes', {
|
||||||
|
variants: {
|
||||||
|
variant: { default: '...', primary: '...' },
|
||||||
|
size: { sm: '...', md: '...' }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
export { Button, buttonVariants }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Without CVA:**
|
||||||
|
```tsx
|
||||||
|
function Label({ className, ...props }) {
|
||||||
|
return <Primitive className={cn('single-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]`
|
||||||
|
- Always use `transition-colors` for hover states
|
||||||
20
.cursor/rules/global.mdc
Normal file
20
.cursor/rules/global.mdc
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
description: Global coding standards that apply to all files
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Global Standards
|
||||||
|
|
||||||
|
You are a professional software engineer. All code must follow best practices: accurate, readable, clean, and efficient.
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
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`.
|
||||||
67
.cursor/rules/sim-architecture.mdc
Normal file
67
.cursor/rules/sim-architecture.mdc
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
---
|
||||||
|
description: Core architecture principles for the Sim app
|
||||||
|
globs: ["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
|
||||||
|
5. **Performance by Default**: useMemo, useCallback, refs appropriately
|
||||||
|
|
||||||
|
## File Organization
|
||||||
|
|
||||||
|
```
|
||||||
|
feature/
|
||||||
|
├── components/ # Feature components
|
||||||
|
│ └── sub-feature/ # Sub-feature with own components
|
||||||
|
├── hooks/ # Custom hooks
|
||||||
|
└── feature.tsx # Main component
|
||||||
|
```
|
||||||
|
|
||||||
|
## Naming Conventions
|
||||||
|
- **Components**: PascalCase (`WorkflowList`, `TriggerPanel`)
|
||||||
|
- **Hooks**: camelCase with `use` prefix (`useWorkflowOperations`)
|
||||||
|
- **Files**: kebab-case matching export (`workflow-list.tsx`)
|
||||||
|
- **Stores**: kebab-case in stores/ (`sidebar/store.ts`)
|
||||||
|
- **Constants**: SCREAMING_SNAKE_CASE
|
||||||
|
- **Interfaces**: PascalCase with suffix (`WorkflowListProps`)
|
||||||
|
|
||||||
|
## State Management
|
||||||
|
|
||||||
|
**useState**: UI-only concerns (dropdown open, hover, form inputs)
|
||||||
|
**Zustand**: Shared state, persistence, global app state
|
||||||
|
**useRef**: DOM refs, avoiding dependency issues, mutable non-reactive values
|
||||||
|
|
||||||
|
## Component Extraction
|
||||||
|
|
||||||
|
**Extract to separate file when:**
|
||||||
|
- Complex (50+ lines)
|
||||||
|
- Used across 2+ files
|
||||||
|
- Has own state/logic
|
||||||
|
|
||||||
|
**Keep inline when:**
|
||||||
|
- Simple (< 10 lines)
|
||||||
|
- Used in only 1 file
|
||||||
|
- Purely presentational
|
||||||
|
|
||||||
|
**Never import utilities from another component file.** Extract shared helpers to `lib/` or `utils/`.
|
||||||
|
|
||||||
|
## Utils Files
|
||||||
|
|
||||||
|
**Never create a `utils.ts` file for a single consumer.** Inline the logic directly in the consuming component.
|
||||||
|
|
||||||
|
**Create `utils.ts` when:**
|
||||||
|
- 2+ files import the same helper
|
||||||
|
|
||||||
|
**Prefer existing sources of truth:**
|
||||||
|
- Before duplicating logic, check if a centralized helper already exists (e.g., `lib/logs/get-trigger-options.ts`)
|
||||||
|
- Import from the source of truth rather than creating wrapper functions
|
||||||
|
|
||||||
|
**Location hierarchy:**
|
||||||
|
- `lib/` — App-wide utilities (auth, billing, core)
|
||||||
|
- `feature/utils.ts` — Feature-scoped utilities (used by 2+ components in the feature)
|
||||||
|
- Inline — Single-use helpers (define directly in the component)
|
||||||
64
.cursor/rules/sim-components.mdc
Normal file
64
.cursor/rules/sim-components.mdc
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
---
|
||||||
|
description: Component patterns and structure for React components
|
||||||
|
globs: ["apps/sim/**/*.tsx"]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Component Patterns
|
||||||
|
|
||||||
|
## Structure Order
|
||||||
|
```typescript
|
||||||
|
'use client' // Only if using hooks
|
||||||
|
|
||||||
|
// 1. Imports (external → internal → relative)
|
||||||
|
// 2. Constants at module level
|
||||||
|
const CONFIG = { SPACING: 8 } as const
|
||||||
|
|
||||||
|
// 3. Props interface with TSDoc
|
||||||
|
interface ComponentProps {
|
||||||
|
/** Description */
|
||||||
|
requiredProp: string
|
||||||
|
optionalProp?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Component with TSDoc
|
||||||
|
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 computations
|
||||||
|
// g. useCallback handlers
|
||||||
|
// h. useEffect
|
||||||
|
// i. Return JSX
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
1. Add `'use client'` when using React hooks
|
||||||
|
2. Always define props interface
|
||||||
|
3. TSDoc on component: description, @param, @returns
|
||||||
|
4. Extract constants with `as const`
|
||||||
|
5. Use Tailwind only, no inline styles
|
||||||
|
6. Semantic HTML (`aside`, `nav`, `article`)
|
||||||
|
7. Include ARIA attributes where appropriate
|
||||||
|
8. Optional chain callbacks: `onAction?.(id)`
|
||||||
|
|
||||||
|
## Factory Pattern with Caching
|
||||||
|
|
||||||
|
When generating components for a specific signature (e.g., icons):
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const cache = new Map<string, React.ComponentType<{ className?: string }>>()
|
||||||
|
|
||||||
|
function getColorIcon(color: string) {
|
||||||
|
if (cache.has(color)) return cache.get(color)!
|
||||||
|
|
||||||
|
const Icon = ({ className }: { className?: string }) => (
|
||||||
|
<div className={cn(className, 'rounded-[3px]')} style={{ backgroundColor: color, width: 10, height: 10 }} />
|
||||||
|
)
|
||||||
|
Icon.displayName = `ColorIcon(${color})`
|
||||||
|
cache.set(color, Icon)
|
||||||
|
return Icon
|
||||||
|
}
|
||||||
|
```
|
||||||
68
.cursor/rules/sim-hooks.mdc
Normal file
68
.cursor/rules/sim-hooks.mdc
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
---
|
||||||
|
description: Custom hook patterns and best practices
|
||||||
|
globs: ["apps/sim/**/use-*.ts", "apps/sim/**/hooks/**/*.ts"]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Hook Patterns
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
```typescript
|
||||||
|
import { createLogger } from '@/lib/logs/console/logger'
|
||||||
|
|
||||||
|
const logger = createLogger('useFeatureName')
|
||||||
|
|
||||||
|
interface UseFeatureProps {
|
||||||
|
id: string
|
||||||
|
onSuccess?: (result: Result) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook description.
|
||||||
|
* @param props - Configuration
|
||||||
|
* @returns State and operations
|
||||||
|
*/
|
||||||
|
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)
|
||||||
|
const [error, setError] = useState<Error | null>(null)
|
||||||
|
|
||||||
|
// 3. Sync refs
|
||||||
|
useEffect(() => {
|
||||||
|
idRef.current = id
|
||||||
|
onSuccessRef.current = onSuccess
|
||||||
|
}, [id, onSuccess])
|
||||||
|
|
||||||
|
// 4. Operations with useCallback
|
||||||
|
const fetchData = useCallback(async () => {
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
const result = await fetch(`/api/${idRef.current}`).then(r => r.json())
|
||||||
|
setData(result)
|
||||||
|
onSuccessRef.current?.(result)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err as Error)
|
||||||
|
logger.error('Failed', { error: err })
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}, []) // Empty deps - using refs
|
||||||
|
|
||||||
|
// 5. Return grouped by state/operations
|
||||||
|
return { data, isLoading, error, fetchData }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
1. Single responsibility per hook
|
||||||
|
2. Props interface required
|
||||||
|
3. TSDoc required
|
||||||
|
4. Use logger, not console.log
|
||||||
|
5. Refs for stable callback dependencies
|
||||||
|
6. Wrap returned functions in useCallback
|
||||||
|
7. Always try/catch async operations
|
||||||
|
8. Track loading/error states
|
||||||
37
.cursor/rules/sim-imports.mdc
Normal file
37
.cursor/rules/sim-imports.mdc
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
description: Import patterns for the Sim application
|
||||||
|
globs: ["apps/sim/**/*.ts", "apps/sim/**/*.tsx"]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Import Patterns
|
||||||
|
|
||||||
|
## EMCN Components
|
||||||
|
Import from `@/components/emcn`, never from subpaths like `@/components/emcn/components/modal/modal`.
|
||||||
|
|
||||||
|
**Exception**: CSS imports use actual file paths: `import '@/components/emcn/components/code/code.css'`
|
||||||
|
|
||||||
|
## Feature Components
|
||||||
|
Import from central folder indexes, not specific subfolders:
|
||||||
|
```typescript
|
||||||
|
// ✅ Correct
|
||||||
|
import { Dashboard, Sidebar } from '@/app/workspace/[workspaceId]/logs/components'
|
||||||
|
|
||||||
|
// ❌ Wrong
|
||||||
|
import { Dashboard } from '@/app/workspace/[workspaceId]/logs/components/dashboard'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Internal vs External
|
||||||
|
- **Cross-feature**: Absolute paths through central index
|
||||||
|
- **Within feature**: Relative paths (`./components/...`, `../utils`)
|
||||||
|
|
||||||
|
## Import Order
|
||||||
|
1. React/core libraries
|
||||||
|
2. External libraries
|
||||||
|
3. UI components (`@/components/emcn`, `@/components/ui`)
|
||||||
|
4. Utilities (`@/lib/...`)
|
||||||
|
5. Feature imports from indexes
|
||||||
|
6. Relative imports
|
||||||
|
7. CSS imports
|
||||||
|
|
||||||
|
## Types
|
||||||
|
Use `type` keyword: `import type { WorkflowLog } from '...'`
|
||||||
57
.cursor/rules/sim-stores.mdc
Normal file
57
.cursor/rules/sim-stores.mdc
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
---
|
||||||
|
description: Zustand store patterns
|
||||||
|
globs: ["apps/sim/**/store.ts", "apps/sim/**/stores/**/*.ts"]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Zustand Store Patterns
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
```typescript
|
||||||
|
import { create } from 'zustand'
|
||||||
|
import { persist } from 'zustand/middleware'
|
||||||
|
|
||||||
|
interface FeatureState {
|
||||||
|
// State
|
||||||
|
items: Item[]
|
||||||
|
activeId: string | null
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
setItems: (items: Item[]) => void
|
||||||
|
addItem: (item: Item) => void
|
||||||
|
clearState: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const createInitialState = () => ({
|
||||||
|
items: [],
|
||||||
|
activeId: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const useFeatureStore = create<FeatureState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
...createInitialState(),
|
||||||
|
|
||||||
|
setItems: (items) => set({ items }),
|
||||||
|
|
||||||
|
addItem: (item) => set((state) => ({
|
||||||
|
items: [...state.items, item],
|
||||||
|
})),
|
||||||
|
|
||||||
|
clearState: () => set(createInitialState()),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'feature-state',
|
||||||
|
partialize: (state) => ({ items: state.items }),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
1. Interface includes state and actions
|
||||||
|
2. Extract config to module constants
|
||||||
|
3. TSDoc on store
|
||||||
|
4. Only persist what's needed
|
||||||
|
5. Immutable updates only - never mutate
|
||||||
|
6. Use `set((state) => ...)` when depending on previous state
|
||||||
|
7. Provide clear/reset actions
|
||||||
47
.cursor/rules/sim-styling.mdc
Normal file
47
.cursor/rules/sim-styling.mdc
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
---
|
||||||
|
description: Tailwind CSS and styling conventions
|
||||||
|
globs: ["apps/sim/**/*.tsx", "apps/sim/**/*.css"]
|
||||||
|
---
|
||||||
|
|
||||||
|
# Styling Rules
|
||||||
|
|
||||||
|
## Tailwind
|
||||||
|
1. **No inline styles** - Use Tailwind classes exclusively
|
||||||
|
2. **No duplicate dark classes** - Don't add `dark:` when value matches light mode
|
||||||
|
3. **Exact values** - Use design system values (`text-[14px]`, `h-[25px]`)
|
||||||
|
4. **Prefer px** - Use `px-[4px]` over `px-1`
|
||||||
|
5. **Transitions** - Add `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 Styles
|
||||||
|
```typescript
|
||||||
|
// In store setter
|
||||||
|
setSidebarWidth: (width) => {
|
||||||
|
set({ sidebarWidth: width })
|
||||||
|
document.documentElement.style.setProperty('--sidebar-width', `${width}px`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// In component
|
||||||
|
<aside style={{ width: 'var(--sidebar-width)' }} />
|
||||||
|
```
|
||||||
|
|
||||||
|
## Anti-Patterns
|
||||||
|
```typescript
|
||||||
|
// ❌ Bad
|
||||||
|
<div style={{ width: 200 }}>
|
||||||
|
<div className='text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
|
||||||
|
|
||||||
|
// ✅ Good
|
||||||
|
<div className='w-[200px]'>
|
||||||
|
<div className='text-[var(--text-primary)]'>
|
||||||
|
```
|
||||||
24
.cursor/rules/sim-typescript.mdc
Normal file
24
.cursor/rules/sim-typescript.mdc
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
description: TypeScript conventions and type safety
|
||||||
|
globs: ["apps/sim/**/*.ts", "apps/sim/**/*.tsx"]
|
||||||
|
---
|
||||||
|
|
||||||
|
# TypeScript Rules
|
||||||
|
|
||||||
|
1. **No `any`** - Use proper types or `unknown` with type guards
|
||||||
|
2. **Props interface** - Always define, even for simple components
|
||||||
|
3. **Callback types** - Full signature with params and return type
|
||||||
|
4. **Generics** - Use for reusable components/hooks
|
||||||
|
5. **Const assertions** - `as const` for constant objects/arrays
|
||||||
|
6. **Ref types** - Explicit: `useRef<HTMLDivElement>(null)`
|
||||||
|
|
||||||
|
## Anti-Patterns
|
||||||
|
```typescript
|
||||||
|
// ❌ Bad
|
||||||
|
const handleClick = (e: any) => {}
|
||||||
|
useEffect(() => { doSomething(prop) }, []) // Missing dep
|
||||||
|
|
||||||
|
// ✅ Good
|
||||||
|
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {}
|
||||||
|
useEffect(() => { doSomething(prop) }, [prop])
|
||||||
|
```
|
||||||
19
.cursorrules
19
.cursorrules
@@ -1,19 +0,0 @@
|
|||||||
# Role
|
|
||||||
|
|
||||||
You are a professional software engineer. All code you write MUST follow best practices, ensuring accuracy, quality, readability, and cleanliness. You MUST make FOCUSED EDITS that are EFFICIENT and ELEGANT.
|
|
||||||
|
|
||||||
## Logs
|
|
||||||
|
|
||||||
ENSURE that you use the logger.info and logger.warn and logger.error instead of the console.log whenever you want to display logs.
|
|
||||||
|
|
||||||
## Comments
|
|
||||||
|
|
||||||
You must use TSDOC for comments. Do not use ==== for comments to separate sections. Do not leave any comments that are not TSDOC.
|
|
||||||
|
|
||||||
## Globals styles
|
|
||||||
|
|
||||||
You should not update the global styles unless it is absolutely necessary. Keep all styling local to components and files.
|
|
||||||
|
|
||||||
## Bun
|
|
||||||
|
|
||||||
Use bun and bunx not npm and npx
|
|
||||||
47
CLAUDE.md
Normal file
47
CLAUDE.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Expert Programming Standards
|
||||||
|
|
||||||
|
**You are tasked with implementing solutions that follow best practices. You MUST be accurate, elegant, and efficient as an expert programmer.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Role
|
||||||
|
|
||||||
|
You are a professional software engineer. All code you write MUST follow best practices, ensuring accuracy, quality, readability, and cleanliness. You MUST make FOCUSED EDITS that are EFFICIENT and ELEGANT.
|
||||||
|
|
||||||
|
## Logs
|
||||||
|
|
||||||
|
ENSURE that you use the logger.info and logger.warn and logger.error instead of the console.log whenever you want to display logs.
|
||||||
|
|
||||||
|
## Comments
|
||||||
|
|
||||||
|
You must use TSDOC for comments. Do not use ==== for comments to separate sections. Do not leave any comments that are not TSDOC.
|
||||||
|
|
||||||
|
## Global Styles
|
||||||
|
|
||||||
|
You should not update the global styles unless it is absolutely necessary. Keep all styling local to components and files.
|
||||||
|
|
||||||
|
## Bun
|
||||||
|
|
||||||
|
Use bun and bunx not npm and npx.
|
||||||
|
|
||||||
|
## Code Quality
|
||||||
|
|
||||||
|
- Write clean, maintainable code that follows the project's existing patterns
|
||||||
|
- Prefer composition over inheritance
|
||||||
|
- Keep functions small and focused on a single responsibility
|
||||||
|
- Use meaningful variable and function names
|
||||||
|
- Handle errors gracefully and provide useful error messages
|
||||||
|
- Write type-safe code with proper TypeScript types
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Write tests for new functionality when appropriate
|
||||||
|
- Ensure existing tests pass before completing work
|
||||||
|
- Follow the project's testing conventions
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- Consider performance implications of your code
|
||||||
|
- Avoid unnecessary re-renders in React components
|
||||||
|
- Use appropriate data structures and algorithms
|
||||||
|
- Profile and optimize when necessary
|
||||||
@@ -1,777 +0,0 @@
|
|||||||
# Sim App Architecture Guidelines
|
|
||||||
|
|
||||||
You are building features in the Sim app following the architecture. This file defines the patterns, structures, and conventions you must follow.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Core Principles
|
|
||||||
|
|
||||||
1. **Single Responsibility Principle**: Each component, hook, and store should have one clear purpose
|
|
||||||
2. **Composition Over Complexity**: Break down complex logic into smaller, composable pieces
|
|
||||||
3. **Type Safety First**: Use TypeScript interfaces for all props, state, and return types
|
|
||||||
4. **Predictable State Management**: Use Zustand for global state, local state for UI-only concerns
|
|
||||||
5. **Performance by Default**: Use useMemo, useCallback, and refs appropriately
|
|
||||||
6. **Accessibility**: Include semantic HTML and ARIA attributes
|
|
||||||
7. **Documentation**: Use TSDoc for all public interfaces and keep it in sync with code changes
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Organization
|
|
||||||
|
|
||||||
### Directory Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
feature/
|
|
||||||
├── components/ # Feature components
|
|
||||||
│ ├── sub-feature/ # Sub-feature with its own components
|
|
||||||
│ │ ├── component.tsx
|
|
||||||
│ │ └── index.ts
|
|
||||||
├── hooks/ # Custom hooks for feature logic
|
|
||||||
│ ├── use-feature-logic.ts
|
|
||||||
│ └── use-another-hook.ts
|
|
||||||
└── feature.tsx # Main feature component
|
|
||||||
```
|
|
||||||
|
|
||||||
### Naming Conventions
|
|
||||||
|
|
||||||
- **Components**: PascalCase with descriptive names (`WorkflowList`, `TriggerPanel`)
|
|
||||||
- **Hooks**: camelCase with `use` prefix (`useWorkflowOperations`, `usePanelResize`)
|
|
||||||
- **Files**: kebab-case matching export name (`workflow-list.tsx`, `use-panel-resize.ts`)
|
|
||||||
- **Stores**: kebab-case in stores/ directory (`sidebar/store.ts`, `workflows/registry/store.ts`)
|
|
||||||
- **Constants**: SCREAMING_SNAKE_CASE at module level
|
|
||||||
- **Interfaces**: PascalCase with descriptive suffix (`WorkflowListProps`, `UseWorkspaceManagementProps`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Component Architecture
|
|
||||||
|
|
||||||
### Component Structure Template
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
'use client' // Only if using hooks like useState, useEffect, etc.
|
|
||||||
|
|
||||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
|
||||||
// Other imports organized: external, internal paths, relative
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Constants - Define at module level before component
|
|
||||||
*/
|
|
||||||
const DEFAULT_VALUE = 100
|
|
||||||
const MIN_VALUE = 50
|
|
||||||
const MAX_VALUE = 200
|
|
||||||
|
|
||||||
const CONFIG = {
|
|
||||||
SPACING: 8,
|
|
||||||
ITEM_HEIGHT: 25,
|
|
||||||
} as const
|
|
||||||
|
|
||||||
interface ComponentProps {
|
|
||||||
/** Description of prop */
|
|
||||||
requiredProp: string
|
|
||||||
/** Description with default noted */
|
|
||||||
optionalProp?: boolean
|
|
||||||
onAction?: (id: string) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Component description explaining purpose and key features.
|
|
||||||
* Mention important integrations, hooks, or patterns used.
|
|
||||||
*
|
|
||||||
* @param props - Component props
|
|
||||||
* @returns JSX description
|
|
||||||
*/
|
|
||||||
export function ComponentName({
|
|
||||||
requiredProp,
|
|
||||||
optionalProp = false,
|
|
||||||
onAction,
|
|
||||||
}: ComponentProps) {
|
|
||||||
// 1. Refs first
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
// 2. External hooks (router, params, context)
|
|
||||||
const params = useParams()
|
|
||||||
|
|
||||||
// 3. Store hooks
|
|
||||||
const { state, actions } = useStore()
|
|
||||||
|
|
||||||
// 4. Custom hooks (your feature hooks)
|
|
||||||
const { data, isLoading } = useCustomHook({ requiredProp })
|
|
||||||
|
|
||||||
// 5. Local state (UI-only concerns)
|
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
|
||||||
|
|
||||||
// 6. Derived/computed values with useMemo
|
|
||||||
const filteredData = useMemo(() => {
|
|
||||||
return data.filter(item => item.active)
|
|
||||||
}, [data])
|
|
||||||
|
|
||||||
// 7. Callbacks with useCallback
|
|
||||||
const handleClick = useCallback((id: string) => {
|
|
||||||
onAction?.(id)
|
|
||||||
}, [onAction])
|
|
||||||
|
|
||||||
// 8. Effects
|
|
||||||
useEffect(() => {
|
|
||||||
// Setup logic
|
|
||||||
return () => {
|
|
||||||
// Cleanup logic
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// 9. Render helpers (if complex)
|
|
||||||
const renderItem = useCallback((item: Item) => (
|
|
||||||
<div key={item.id}>{item.name}</div>
|
|
||||||
), [])
|
|
||||||
|
|
||||||
// 10. Return JSX
|
|
||||||
return (
|
|
||||||
<div ref={containerRef} className='...' aria-label='...'>
|
|
||||||
{/* Section comments for clarity */}
|
|
||||||
{/* Header */}
|
|
||||||
<header>...</header>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<main>...</main>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Component Rules
|
|
||||||
|
|
||||||
1. **Client Components**: Add `'use client'` directive when using React hooks
|
|
||||||
2. **Props Interface**: Always define TypeScript interface, even for simple props
|
|
||||||
3. **TSDoc Required and Up-to-Date**: Include description, @param, and @returns. Update TSDoc whenever props, behavior, or side effects change (including additions and deletions).
|
|
||||||
4. **Constants**: Extract magic numbers and config to module-level constants using `as const`
|
|
||||||
5. **No Inline Styles**: Use Tailwind classes exclusively (CSS variables for dynamic values)
|
|
||||||
6. **Section Comments**: Use comments to mark logical sections of JSX
|
|
||||||
7. **Semantic HTML**: Use appropriate elements (`aside`, `nav`, `article`, etc.)
|
|
||||||
8. **ARIA Attributes**: Include `aria-label`, `aria-orientation`, `role` where appropriate
|
|
||||||
9. **Refs for DOM**: Use refs for direct DOM access, not state
|
|
||||||
10. **Callback Props**: Always use optional chaining for callback props (`onAction?.(...)`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Custom Hooks Architecture
|
|
||||||
|
|
||||||
### Hook Structure Template
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
|
||||||
import { createLogger } from '@/lib/logs/console/logger'
|
|
||||||
|
|
||||||
const logger = createLogger('useFeatureName')
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Constants specific to this hook
|
|
||||||
*/
|
|
||||||
const DEFAULT_CONFIG = {
|
|
||||||
timeout: 1000,
|
|
||||||
retries: 3,
|
|
||||||
} as const
|
|
||||||
|
|
||||||
interface UseFeatureNameProps {
|
|
||||||
/** Description of required prop */
|
|
||||||
id: string
|
|
||||||
/** Optional callback fired on success */
|
|
||||||
onSuccess?: (result: Result) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom hook to [clear description of purpose].
|
|
||||||
* [Additional context about what it manages or coordinates].
|
|
||||||
*
|
|
||||||
* @param props - Configuration object containing id and callbacks
|
|
||||||
* @returns Feature state and operations
|
|
||||||
*/
|
|
||||||
export function useFeatureName({ id, onSuccess }: UseFeatureNameProps) {
|
|
||||||
// 1. Refs (to avoid dependency issues)
|
|
||||||
const idRef = useRef(id)
|
|
||||||
const onSuccessRef = useRef(onSuccess)
|
|
||||||
|
|
||||||
// 2. State
|
|
||||||
const [data, setData] = useState<Data | null>(null)
|
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
|
||||||
const [error, setError] = useState<Error | null>(null)
|
|
||||||
|
|
||||||
// 3. Update refs when values change
|
|
||||||
useEffect(() => {
|
|
||||||
idRef.current = id
|
|
||||||
onSuccessRef.current = onSuccess
|
|
||||||
}, [id, onSuccess])
|
|
||||||
|
|
||||||
// 4. Operations with useCallback (stable references)
|
|
||||||
const fetchData = useCallback(async () => {
|
|
||||||
setIsLoading(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/data/${idRef.current}`)
|
|
||||||
const result = await response.json()
|
|
||||||
setData(result)
|
|
||||||
onSuccessRef.current?.(result)
|
|
||||||
logger.info('Data fetched successfully', { id: idRef.current })
|
|
||||||
} catch (err) {
|
|
||||||
const error = err as Error
|
|
||||||
setError(error)
|
|
||||||
logger.error('Failed to fetch data', { error })
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
}, []) // Empty deps because using refs
|
|
||||||
|
|
||||||
const updateData = useCallback(async (newData: Partial<Data>) => {
|
|
||||||
try {
|
|
||||||
logger.info('Updating data', { id: idRef.current, newData })
|
|
||||||
const response = await fetch(`/api/data/${idRef.current}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(newData),
|
|
||||||
})
|
|
||||||
const result = await response.json()
|
|
||||||
setData(result)
|
|
||||||
return true
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('Failed to update data', { error: err })
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// 5. Effects
|
|
||||||
useEffect(() => {
|
|
||||||
if (id) {
|
|
||||||
fetchData()
|
|
||||||
}
|
|
||||||
}, [id, fetchData])
|
|
||||||
|
|
||||||
// 6. Return object - group by state and operations
|
|
||||||
return {
|
|
||||||
// State
|
|
||||||
data,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
|
|
||||||
// Operations
|
|
||||||
fetchData,
|
|
||||||
updateData,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Hook Rules
|
|
||||||
|
|
||||||
1. **Single Responsibility**: Each hook manages one concern (data fetching, resize, navigation)
|
|
||||||
2. **Props Interface**: Define TypeScript interface for all parameters
|
|
||||||
3. **TSDoc Required and Up-to-Date**: Include clear description, @param, and @returns. Update TSDoc whenever inputs, outputs, behavior, or side effects change (including additions and deletions).
|
|
||||||
4. **Logger Usage**: Import and use logger instead of console.log
|
|
||||||
5. **Refs for Stable Deps**: Use refs to avoid recreating callbacks unnecessarily
|
|
||||||
6. **useCallback Always**: Wrap all returned functions in useCallback
|
|
||||||
7. **Grouped Returns**: Return object with comments separating State and Operations
|
|
||||||
8. **Error Handling**: Always try/catch async operations and log errors
|
|
||||||
9. **Loading States**: Track loading, error states for async operations
|
|
||||||
10. **Dependency Arrays**: Be explicit and correct with all dependency arrays
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Store Architecture (Zustand)
|
|
||||||
|
|
||||||
### Store Structure Template
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { create } from 'zustand'
|
|
||||||
import { persist } from 'zustand/middleware'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Store state interface
|
|
||||||
*/
|
|
||||||
interface FeatureState {
|
|
||||||
// State properties
|
|
||||||
items: Item[]
|
|
||||||
activeId: string | null
|
|
||||||
isLoading: boolean
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
setItems: (items: Item[]) => void
|
|
||||||
setActiveId: (id: string | null) => void
|
|
||||||
addItem: (item: Item) => void
|
|
||||||
removeItem: (id: string) => void
|
|
||||||
clearState: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Constants - Configuration values
|
|
||||||
*/
|
|
||||||
const DEFAULT_CONFIG = {
|
|
||||||
maxItems: 100,
|
|
||||||
cacheTime: 3600,
|
|
||||||
} as const
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initial state factory
|
|
||||||
*/
|
|
||||||
const createInitialState = () => ({
|
|
||||||
items: [],
|
|
||||||
activeId: null,
|
|
||||||
isLoading: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Feature store managing [description].
|
|
||||||
* [Additional context about what this store coordinates].
|
|
||||||
*/
|
|
||||||
export const useFeatureStore = create<FeatureState>()(
|
|
||||||
persist(
|
|
||||||
(set, get) => ({
|
|
||||||
...createInitialState(),
|
|
||||||
|
|
||||||
setItems: (items) => set({ items }),
|
|
||||||
|
|
||||||
setActiveId: (id) => set({ activeId: id }),
|
|
||||||
|
|
||||||
addItem: (item) =>
|
|
||||||
set((state) => ({
|
|
||||||
items: [...state.items, item].slice(-DEFAULT_CONFIG.maxItems),
|
|
||||||
})),
|
|
||||||
|
|
||||||
removeItem: (id) =>
|
|
||||||
set((state) => ({
|
|
||||||
items: state.items.filter((item) => item.id !== id),
|
|
||||||
activeId: state.activeId === id ? null : state.activeId,
|
|
||||||
})),
|
|
||||||
|
|
||||||
clearState: () => set(createInitialState()),
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
name: 'feature-state',
|
|
||||||
// Optionally customize what to persist
|
|
||||||
partialize: (state) => ({
|
|
||||||
items: state.items,
|
|
||||||
activeId: state.activeId,
|
|
||||||
}),
|
|
||||||
onRehydrateStorage: () => (state) => {
|
|
||||||
// Validate and transform persisted state if needed
|
|
||||||
if (state) {
|
|
||||||
// Enforce constraints
|
|
||||||
if (state.items.length > DEFAULT_CONFIG.maxItems) {
|
|
||||||
state.items = state.items.slice(-DEFAULT_CONFIG.maxItems)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Store Rules
|
|
||||||
|
|
||||||
1. **Interface First**: Define TypeScript interface including both state and actions
|
|
||||||
2. **Constants**: Extract configuration values to module-level constants
|
|
||||||
3. **TSDoc on Store**: Document what the store manages
|
|
||||||
4. **Persist Strategically**: Only persist what's needed across sessions
|
|
||||||
5. **Validation**: Use onRehydrateStorage to validate persisted state
|
|
||||||
6. **Immutable Updates**: Use set() with new objects/arrays, never mutate
|
|
||||||
7. **Derived State**: Use getters or selectors, not stored computed values
|
|
||||||
8. **CSS Variables**: Update CSS variables in setters for hydration-safe dynamic styles
|
|
||||||
9. **Cleanup Actions**: Provide clear/reset actions for state cleanup
|
|
||||||
10. **Functional Updates**: Use `set((state) => ...)` when new state depends on old state
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## State Management Strategy
|
|
||||||
|
|
||||||
### When to Use Local State (useState)
|
|
||||||
|
|
||||||
- UI-only concerns (dropdown open, hover states, form inputs)
|
|
||||||
- Component-scoped state not needed elsewhere
|
|
||||||
- Temporary state that doesn't need persistence
|
|
||||||
|
|
||||||
### When to Use Zustand Store
|
|
||||||
|
|
||||||
- Shared state across multiple components
|
|
||||||
- State that needs persistence (localStorage)
|
|
||||||
- Global application state (user, theme, settings)
|
|
||||||
- Complex state with many actions/reducers
|
|
||||||
|
|
||||||
### When to Use Refs (useRef)
|
|
||||||
|
|
||||||
- DOM element references
|
|
||||||
- Avoiding dependency issues in hooks
|
|
||||||
- Storing mutable values that don't trigger re-renders
|
|
||||||
- Accessing latest props/state in callbacks without recreating them
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## CSS and Styling
|
|
||||||
|
|
||||||
### CSS Variables for Dynamic Styles
|
|
||||||
|
|
||||||
Use CSS variables for values that need to persist across hydration:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// In store setter
|
|
||||||
setSidebarWidth: (width) => {
|
|
||||||
const clampedWidth = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, width))
|
|
||||||
set({ sidebarWidth: clampedWidth })
|
|
||||||
|
|
||||||
// Update CSS variable for immediate visual feedback
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
document.documentElement.style.setProperty('--sidebar-width', `${clampedWidth}px`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// In component
|
|
||||||
<aside
|
|
||||||
className='sidebar-container'
|
|
||||||
style={{ width: 'var(--sidebar-width)' }}
|
|
||||||
>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Tailwind Classes
|
|
||||||
|
|
||||||
1. **No Inline Styles**: Use Tailwind utility classes exclusively
|
|
||||||
2. **Dark Mode**: Include dark mode variants only when the value differs from light mode
|
|
||||||
3. **No Duplicate Dark Classes**: Never add a `dark:` class when the value is identical to the light mode class (e.g., `text-[var(--text-primary)] dark:text-[var(--text-primary)]` is redundant - just use `text-[var(--text-primary)]`)
|
|
||||||
4. **Exact Values**: Use exact values from design system (`text-[14px]`, `h-[25px]`)
|
|
||||||
5. **cn for Conditionals**: Use `cn()` from `@/lib/utils` for conditional classes (wraps clsx + tailwind-merge for conflict resolution)
|
|
||||||
6. **Consistent Spacing**: Use spacing tokens (`gap-[8px]`, `px-[14px]`)
|
|
||||||
7. **Transitions**: Add transitions for interactive states (`transition-colors`)
|
|
||||||
8. **Prefer px units**: Use arbitrary px values over scale utilities (e.g., `px-[4px]` instead of `px-1`)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'base-classes that-always-apply',
|
|
||||||
isActive && 'active-state-classes',
|
|
||||||
disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer hover:bg-accent'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## TypeScript Patterns
|
|
||||||
|
|
||||||
### Interface Conventions
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Component props
|
|
||||||
interface ComponentNameProps {
|
|
||||||
requiredProp: string
|
|
||||||
optionalProp?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hook props
|
|
||||||
interface UseHookNameProps {
|
|
||||||
id: string
|
|
||||||
onSuccess?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store state
|
|
||||||
interface FeatureState {
|
|
||||||
data: Data[]
|
|
||||||
isLoading: boolean
|
|
||||||
actions: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return types (if complex)
|
|
||||||
interface UseHookNameReturn {
|
|
||||||
state: State
|
|
||||||
actions: Actions
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Type Safety Rules
|
|
||||||
|
|
||||||
1. **No `any`**: Use proper types or `unknown` with type guards
|
|
||||||
2. **Props Interface**: Always define, even for simple components
|
|
||||||
3. **Callback Types**: Define full signature including parameters and return type
|
|
||||||
4. **Generic Types**: Use generics for reusable components/hooks
|
|
||||||
5. **Const Assertions**: Use `as const` for constant objects/arrays
|
|
||||||
6. **Type Guards**: Create type guards for runtime checks
|
|
||||||
7. **Ref Types**: Explicitly type refs (`useRef<HTMLDivElement>(null)`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Performance Patterns
|
|
||||||
|
|
||||||
### Memoization
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// useMemo for expensive computations
|
|
||||||
const sortedItems = useMemo(() => {
|
|
||||||
return items.sort((a, b) => a.name.localeCompare(b.name))
|
|
||||||
}, [items])
|
|
||||||
|
|
||||||
// useCallback for functions passed as props
|
|
||||||
const handleClick = useCallback((id: string) => {
|
|
||||||
onItemClick?.(id)
|
|
||||||
}, [onItemClick])
|
|
||||||
|
|
||||||
// useCallback for render functions
|
|
||||||
const renderItem = useCallback((item: Item) => (
|
|
||||||
<ItemComponent key={item.id} item={item} onClick={handleClick} />
|
|
||||||
), [handleClick])
|
|
||||||
```
|
|
||||||
|
|
||||||
### When to Memoize
|
|
||||||
|
|
||||||
1. **useMemo**: Expensive calculations, filtering/sorting large arrays, object creation in render
|
|
||||||
2. **useCallback**: Functions passed to child components, dependencies in other hooks, event handlers used in effects
|
|
||||||
3. **Don't Over-Memoize**: Simple calculations, primitives, or functions not passed down
|
|
||||||
|
|
||||||
### Refs for Avoiding Recreations
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// Pattern: Use refs to avoid function recreations
|
|
||||||
const onSuccessRef = useRef(onSuccess)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onSuccessRef.current = onSuccess
|
|
||||||
}, [onSuccess])
|
|
||||||
|
|
||||||
const stableCallback = useCallback(() => {
|
|
||||||
// Use ref so this callback never needs to change
|
|
||||||
onSuccessRef.current?.()
|
|
||||||
}, []) // Empty deps!
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Logging and Debugging
|
|
||||||
|
|
||||||
### Logger Usage
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { createLogger } from '@/lib/logs/console/logger'
|
|
||||||
|
|
||||||
const logger = createLogger('ComponentName')
|
|
||||||
|
|
||||||
// Use throughout component/hook
|
|
||||||
logger.info('User action', { userId, action })
|
|
||||||
logger.warn('Potential issue', { details })
|
|
||||||
logger.error('Operation failed', { error })
|
|
||||||
```
|
|
||||||
|
|
||||||
### Logging Rules
|
|
||||||
|
|
||||||
1. **No console.log**: Use logger.info/warn/error instead
|
|
||||||
2. **Logger Per File**: Create logger with component/hook name
|
|
||||||
3. **Structured Logging**: Pass objects with context, not just strings
|
|
||||||
4. **Log Levels**:
|
|
||||||
- `info`: Normal operations, user actions, state changes
|
|
||||||
- `warn`: Unusual but handled situations, deprecations
|
|
||||||
- `error`: Failures, exceptions, errors that need attention
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Linting and Formatting
|
|
||||||
|
|
||||||
### Automated Linting
|
|
||||||
|
|
||||||
**Do not manually fix linting errors.** The project uses automated linting tools that should handle formatting and style issues.
|
|
||||||
|
|
||||||
### Rules
|
|
||||||
|
|
||||||
1. **No Manual Fixes**: Do not attempt to manually reorder CSS classes, fix formatting, or address linter warnings
|
|
||||||
2. **Use Automated Tools**: If linting errors need to be fixed, run `bun run lint` to let the automated tools handle it
|
|
||||||
3. **Focus on Logic**: Concentrate on functionality, TypeScript correctness, and architectural patterns
|
|
||||||
4. **Let Tools Handle Style**: Biome and other linters will automatically format code according to project standards
|
|
||||||
|
|
||||||
### When Linting Matters
|
|
||||||
|
|
||||||
- **Syntax Errors**: Fix actual syntax errors that prevent compilation
|
|
||||||
- **Type Errors**: Address TypeScript type errors that indicate logic issues
|
|
||||||
- **Ignore Style Warnings**: CSS class order, formatting preferences, etc. will be handled by tooling
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# If linting is required
|
|
||||||
bun run lint
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Code Quality Checklist
|
|
||||||
|
|
||||||
Before considering a component/hook complete, verify:
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
- [ ] TSDoc in sync with implementation after any change (params/returns/behavior/throws)
|
|
||||||
- [ ] TSDoc comment on component/hook/store
|
|
||||||
- [ ] Props interface documented with /** */ comments
|
|
||||||
- [ ] Complex logic explained with inline comments
|
|
||||||
- [ ] Section comments in JSX for clarity
|
|
||||||
|
|
||||||
### TypeScript
|
|
||||||
- [ ] All props have interface defined
|
|
||||||
- [ ] No `any` types used
|
|
||||||
- [ ] Refs properly typed
|
|
||||||
- [ ] Return types explicit for complex hooks
|
|
||||||
|
|
||||||
### Performance
|
|
||||||
- [ ] useMemo for expensive computations
|
|
||||||
- [ ] useCallback for functions passed as props
|
|
||||||
- [ ] Refs used to avoid unnecessary recreations
|
|
||||||
- [ ] No unnecessary re-renders
|
|
||||||
|
|
||||||
### Hooks
|
|
||||||
- [ ] Correct dependency arrays
|
|
||||||
- [ ] Cleanup in useEffect return functions
|
|
||||||
- [ ] Stable callback references with useCallback
|
|
||||||
- [ ] Logic extracted to custom hooks when reusable
|
|
||||||
|
|
||||||
### Styling
|
|
||||||
- [ ] No styles attributes (use className with Tailwind)
|
|
||||||
- [ ] Dark mode variants only when values differ from light mode
|
|
||||||
- [ ] No duplicate dark: classes with identical values
|
|
||||||
- [ ] Consistent spacing using design tokens
|
|
||||||
- [ ] cn() for conditional classes
|
|
||||||
|
|
||||||
### Accessibility
|
|
||||||
- [ ] Semantic HTML elements
|
|
||||||
- [ ] ARIA labels and roles where needed
|
|
||||||
- [ ] Keyboard navigation support
|
|
||||||
- [ ] Focus management
|
|
||||||
|
|
||||||
### State Management
|
|
||||||
- [ ] Local state for UI-only concerns
|
|
||||||
- [ ] Zustand for shared/persisted state
|
|
||||||
- [ ] No duplicate state
|
|
||||||
- [ ] Clear state update patterns
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
- [ ] try/catch around async operations
|
|
||||||
- [ ] Error states tracked and displayed
|
|
||||||
- [ ] Loading states for async actions
|
|
||||||
- [ ] Failures logged with context
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Anti-Patterns to Avoid
|
|
||||||
|
|
||||||
### ❌ Don't Do This
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ❌ Inline styles
|
|
||||||
<div style={{ width: 200, marginTop: 10 }}>
|
|
||||||
|
|
||||||
// ❌ Duplicate dark mode classes (same value as light mode)
|
|
||||||
<div className='text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
|
|
||||||
<div className='bg-[var(--surface-9)] dark:bg-[var(--surface-9)]'>
|
|
||||||
<div className='hover:bg-[var(--border)] dark:hover:bg-[var(--border)]'>
|
|
||||||
|
|
||||||
// ❌ console.log
|
|
||||||
console.log('Debug info')
|
|
||||||
|
|
||||||
// ❌ any type
|
|
||||||
const handleClick = (e: any) => {}
|
|
||||||
|
|
||||||
// ❌ Missing dependencies
|
|
||||||
useEffect(() => {
|
|
||||||
doSomething(prop)
|
|
||||||
}, []) // Missing prop!
|
|
||||||
|
|
||||||
// ❌ Mutating state
|
|
||||||
const handleAdd = () => {
|
|
||||||
items.push(newItem) // Mutating!
|
|
||||||
setItems(items)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ❌ No error handling
|
|
||||||
const fetchData = async () => {
|
|
||||||
const data = await fetch('/api/data')
|
|
||||||
setData(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ❌ Complex logic in component
|
|
||||||
export function Component() {
|
|
||||||
const [data, setData] = useState([])
|
|
||||||
useEffect(() => {
|
|
||||||
// 50 lines of complex logic
|
|
||||||
}, [])
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### ✅ Do This Instead
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// ✅ Tailwind classes
|
|
||||||
<div className='w-[200px] mt-[10px]'>
|
|
||||||
|
|
||||||
// ✅ No duplicate dark classes - CSS variables already handle theming
|
|
||||||
<div className='text-[var(--text-primary)]'>
|
|
||||||
<div className='bg-[var(--surface-9)]'>
|
|
||||||
<div className='hover:bg-[var(--border)]'>
|
|
||||||
|
|
||||||
// ✅ Only add dark: when values differ between modes
|
|
||||||
<div className='bg-[var(--surface-6)] dark:bg-[var(--surface-9)]'>
|
|
||||||
|
|
||||||
// ✅ Logger
|
|
||||||
logger.info('Debug info', { context })
|
|
||||||
|
|
||||||
// ✅ Proper types
|
|
||||||
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {}
|
|
||||||
|
|
||||||
// ✅ Complete dependencies
|
|
||||||
useEffect(() => {
|
|
||||||
doSomething(prop)
|
|
||||||
}, [prop])
|
|
||||||
|
|
||||||
// ✅ Immutable updates
|
|
||||||
const handleAdd = () => {
|
|
||||||
setItems([...items, newItem])
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ Error handling
|
|
||||||
const fetchData = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/data')
|
|
||||||
if (!response.ok) throw new Error('Failed to fetch')
|
|
||||||
const data = await response.json()
|
|
||||||
setData(data)
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Fetch failed', { error })
|
|
||||||
setError(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ✅ Extract to custom hook
|
|
||||||
export function Component() {
|
|
||||||
const { data, isLoading, error } = useFeatureData()
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Examples from Codebase
|
|
||||||
|
|
||||||
Study these files as reference implementations:
|
|
||||||
|
|
||||||
### Components
|
|
||||||
- `sidebar-new.tsx` - Main component structure, hook composition
|
|
||||||
- `workflow-list.tsx` - Complex component with drag-drop, memoization
|
|
||||||
- `blocks.tsx` - Simple panel component with resize
|
|
||||||
- `triggers.tsx` - Similar panel pattern
|
|
||||||
|
|
||||||
### Hooks
|
|
||||||
- `use-workspace-management.ts` - Complex hook with multiple operations, refs pattern
|
|
||||||
- `use-sidebar-resize.ts` - Simple focused hook with event listeners
|
|
||||||
- `use-workflow-operations.ts` - Hook coordinating store and navigation
|
|
||||||
- `use-panel-resize.ts` - Shared resize logic pattern
|
|
||||||
|
|
||||||
### Stores
|
|
||||||
- `stores/sidebar/store.ts` - Persist middleware, CSS variables, validation
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
This architecture prioritizes:
|
|
||||||
|
|
||||||
1. **Separation of Concerns**: Components render, hooks contain logic, stores manage state
|
|
||||||
2. **Type Safety**: TypeScript everywhere with no escape hatches
|
|
||||||
3. **Performance**: Smart use of memoization and refs
|
|
||||||
4. **Maintainability**: Clear structure, documentation, and consistent patterns
|
|
||||||
5. **Developer Experience**: Logging, error handling, and clear interfaces
|
|
||||||
|
|
||||||
When in doubt, follow the patterns established in the sidebar-new component family.
|
|
||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
ENTERPRISE_PLAN_FEATURES,
|
ENTERPRISE_PLAN_FEATURES,
|
||||||
PRO_PLAN_FEATURES,
|
PRO_PLAN_FEATURES,
|
||||||
TEAM_PLAN_FEATURES,
|
TEAM_PLAN_FEATURES,
|
||||||
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/plan-configs'
|
} from '@/app/workspace/[workspaceId]/w/components/sidebar/components/settings-modal/components/subscription/plan-configs'
|
||||||
|
|
||||||
const logger = createLogger('LandingPricing')
|
const logger = createLogger('LandingPricing')
|
||||||
|
|
||||||
|
|||||||
@@ -206,6 +206,9 @@
|
|||||||
--terminal-status-info-bg: #f5f5f4; /* stone-100 */
|
--terminal-status-info-bg: #f5f5f4; /* stone-100 */
|
||||||
--terminal-status-info-border: #a8a29e; /* stone-400 */
|
--terminal-status-info-border: #a8a29e; /* stone-400 */
|
||||||
--terminal-status-info-color: #57534e; /* stone-600 */
|
--terminal-status-info-color: #57534e; /* stone-600 */
|
||||||
|
--terminal-status-warning-bg: #fef9e7;
|
||||||
|
--terminal-status-warning-border: #f5c842;
|
||||||
|
--terminal-status-warning-color: #a16207;
|
||||||
}
|
}
|
||||||
.dark {
|
.dark {
|
||||||
/* Neutrals (surfaces) */
|
/* Neutrals (surfaces) */
|
||||||
@@ -336,6 +339,9 @@
|
|||||||
--terminal-status-info-bg: #383838;
|
--terminal-status-info-bg: #383838;
|
||||||
--terminal-status-info-border: #686868;
|
--terminal-status-info-border: #686868;
|
||||||
--terminal-status-info-color: #b7b7b7;
|
--terminal-status-info-color: #b7b7b7;
|
||||||
|
--terminal-status-warning-bg: #3d3520;
|
||||||
|
--terminal-status-warning-border: #5c4d1f;
|
||||||
|
--terminal-status-warning-color: #d4a72c;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { db } from '@sim/db'
|
import { db } from '@sim/db'
|
||||||
import { pausedExecutions, permissions, workflow, workflowExecutionLogs } from '@sim/db/schema'
|
import { pausedExecutions, permissions, workflow, workflowExecutionLogs } from '@sim/db/schema'
|
||||||
import { and, desc, eq, gte, inArray, lte, type SQL, sql } from 'drizzle-orm'
|
import { and, desc, eq, gte, inArray, isNotNull, isNull, lte, or, type SQL, sql } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
@@ -126,13 +126,50 @@ export async function GET(request: NextRequest) {
|
|||||||
// Build additional conditions for the query
|
// Build additional conditions for the query
|
||||||
let conditions: SQL | undefined
|
let conditions: SQL | undefined
|
||||||
|
|
||||||
// Filter by level (supports comma-separated for OR conditions)
|
// Filter by level with support for derived statuses (running, pending)
|
||||||
if (params.level && params.level !== 'all') {
|
if (params.level && params.level !== 'all') {
|
||||||
const levels = params.level.split(',').filter(Boolean)
|
const levels = params.level.split(',').filter(Boolean)
|
||||||
if (levels.length === 1) {
|
const levelConditions: SQL[] = []
|
||||||
conditions = and(conditions, eq(workflowExecutionLogs.level, levels[0]))
|
|
||||||
} else if (levels.length > 1) {
|
for (const level of levels) {
|
||||||
conditions = and(conditions, inArray(workflowExecutionLogs.level, levels))
|
if (level === 'error') {
|
||||||
|
// Direct database field
|
||||||
|
levelConditions.push(eq(workflowExecutionLogs.level, 'error'))
|
||||||
|
} else if (level === 'info') {
|
||||||
|
// Completed info logs only (not running, not pending)
|
||||||
|
const condition = and(
|
||||||
|
eq(workflowExecutionLogs.level, 'info'),
|
||||||
|
isNotNull(workflowExecutionLogs.endedAt)
|
||||||
|
)
|
||||||
|
if (condition) levelConditions.push(condition)
|
||||||
|
} else if (level === 'running') {
|
||||||
|
// Running logs: info level with no endedAt
|
||||||
|
const condition = and(
|
||||||
|
eq(workflowExecutionLogs.level, 'info'),
|
||||||
|
isNull(workflowExecutionLogs.endedAt)
|
||||||
|
)
|
||||||
|
if (condition) levelConditions.push(condition)
|
||||||
|
} else if (level === 'pending') {
|
||||||
|
// Pending logs: info level with pause status indicators
|
||||||
|
const condition = and(
|
||||||
|
eq(workflowExecutionLogs.level, 'info'),
|
||||||
|
or(
|
||||||
|
sql`(${pausedExecutions.totalPauseCount} > 0 AND ${pausedExecutions.resumedCount} < ${pausedExecutions.totalPauseCount})`,
|
||||||
|
and(
|
||||||
|
isNotNull(pausedExecutions.status),
|
||||||
|
sql`${pausedExecutions.status} != 'fully_resumed'`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (condition) levelConditions.push(condition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (levelConditions.length > 0) {
|
||||||
|
conditions = and(
|
||||||
|
conditions,
|
||||||
|
levelConditions.length === 1 ? levelConditions[0] : or(...levelConditions)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { db } from '@sim/db'
|
import { db } from '@sim/db'
|
||||||
import { permissions, workflow, workflowExecutionLogs } from '@sim/db/schema'
|
import { pausedExecutions, permissions, workflow, workflowExecutionLogs } from '@sim/db/schema'
|
||||||
import { and, eq, gte, inArray, lte } from 'drizzle-orm'
|
import { and, eq, gte, inArray, isNotNull, isNull, lte, or, type SQL, sql } from 'drizzle-orm'
|
||||||
import { type NextRequest, NextResponse } from 'next/server'
|
import { type NextRequest, NextResponse } from 'next/server'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { getSession } from '@/lib/auth'
|
import { getSession } from '@/lib/auth'
|
||||||
@@ -15,6 +15,7 @@ const QueryParamsSchema = z.object({
|
|||||||
workflowIds: z.string().optional(),
|
workflowIds: z.string().optional(),
|
||||||
folderIds: z.string().optional(),
|
folderIds: z.string().optional(),
|
||||||
triggers: z.string().optional(),
|
triggers: z.string().optional(),
|
||||||
|
level: z.string().optional(), // Supports comma-separated values: 'error,running'
|
||||||
})
|
})
|
||||||
|
|
||||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
@@ -84,20 +85,73 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
|
|||||||
inArray(workflowExecutionLogs.workflowId, workflowIdList),
|
inArray(workflowExecutionLogs.workflowId, workflowIdList),
|
||||||
gte(workflowExecutionLogs.startedAt, start),
|
gte(workflowExecutionLogs.startedAt, start),
|
||||||
lte(workflowExecutionLogs.startedAt, end),
|
lte(workflowExecutionLogs.startedAt, end),
|
||||||
] as any[]
|
] as SQL[]
|
||||||
if (qp.triggers) {
|
if (qp.triggers) {
|
||||||
const t = qp.triggers.split(',').filter(Boolean)
|
const t = qp.triggers.split(',').filter(Boolean)
|
||||||
logWhere.push(inArray(workflowExecutionLogs.trigger, t))
|
logWhere.push(inArray(workflowExecutionLogs.trigger, t))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle level filtering with support for derived statuses and multiple selections
|
||||||
|
if (qp.level && qp.level !== 'all') {
|
||||||
|
const levels = qp.level.split(',').filter(Boolean)
|
||||||
|
const levelConditions: SQL[] = []
|
||||||
|
|
||||||
|
for (const level of levels) {
|
||||||
|
if (level === 'error') {
|
||||||
|
levelConditions.push(eq(workflowExecutionLogs.level, 'error'))
|
||||||
|
} else if (level === 'info') {
|
||||||
|
// Completed info logs only
|
||||||
|
const condition = and(
|
||||||
|
eq(workflowExecutionLogs.level, 'info'),
|
||||||
|
isNotNull(workflowExecutionLogs.endedAt)
|
||||||
|
)
|
||||||
|
if (condition) levelConditions.push(condition)
|
||||||
|
} else if (level === 'running') {
|
||||||
|
// Running logs: info level with no endedAt
|
||||||
|
const condition = and(
|
||||||
|
eq(workflowExecutionLogs.level, 'info'),
|
||||||
|
isNull(workflowExecutionLogs.endedAt)
|
||||||
|
)
|
||||||
|
if (condition) levelConditions.push(condition)
|
||||||
|
} else if (level === 'pending') {
|
||||||
|
// Pending logs: info level with pause status indicators
|
||||||
|
const condition = and(
|
||||||
|
eq(workflowExecutionLogs.level, 'info'),
|
||||||
|
or(
|
||||||
|
sql`(${pausedExecutions.totalPauseCount} > 0 AND ${pausedExecutions.resumedCount} < ${pausedExecutions.totalPauseCount})`,
|
||||||
|
and(
|
||||||
|
isNotNull(pausedExecutions.status),
|
||||||
|
sql`${pausedExecutions.status} != 'fully_resumed'`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (condition) levelConditions.push(condition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (levelConditions.length > 0) {
|
||||||
|
const combinedCondition =
|
||||||
|
levelConditions.length === 1 ? levelConditions[0] : or(...levelConditions)
|
||||||
|
if (combinedCondition) logWhere.push(combinedCondition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const logs = await db
|
const logs = await db
|
||||||
.select({
|
.select({
|
||||||
workflowId: workflowExecutionLogs.workflowId,
|
workflowId: workflowExecutionLogs.workflowId,
|
||||||
level: workflowExecutionLogs.level,
|
level: workflowExecutionLogs.level,
|
||||||
startedAt: workflowExecutionLogs.startedAt,
|
startedAt: workflowExecutionLogs.startedAt,
|
||||||
|
endedAt: workflowExecutionLogs.endedAt,
|
||||||
totalDurationMs: workflowExecutionLogs.totalDurationMs,
|
totalDurationMs: workflowExecutionLogs.totalDurationMs,
|
||||||
|
pausedTotalPauseCount: pausedExecutions.totalPauseCount,
|
||||||
|
pausedResumedCount: pausedExecutions.resumedCount,
|
||||||
|
pausedStatus: pausedExecutions.status,
|
||||||
})
|
})
|
||||||
.from(workflowExecutionLogs)
|
.from(workflowExecutionLogs)
|
||||||
|
.leftJoin(
|
||||||
|
pausedExecutions,
|
||||||
|
eq(pausedExecutions.executionId, workflowExecutionLogs.executionId)
|
||||||
|
)
|
||||||
.where(and(...logWhere))
|
.where(and(...logWhere))
|
||||||
|
|
||||||
type Bucket = {
|
type Bucket = {
|
||||||
|
|||||||
@@ -4,11 +4,41 @@ import { useEffect, useState } from 'react'
|
|||||||
import { useParams, useRouter, useSearchParams } from 'next/navigation'
|
import { useParams, useRouter, useSearchParams } from 'next/navigation'
|
||||||
import { client, useSession } from '@/lib/auth/auth-client'
|
import { client, useSession } from '@/lib/auth/auth-client'
|
||||||
import { createLogger } from '@/lib/logs/console/logger'
|
import { createLogger } from '@/lib/logs/console/logger'
|
||||||
import { getErrorMessage } from '@/app/invite/[id]/utils'
|
|
||||||
import { InviteLayout, InviteStatusCard } from '@/app/invite/components'
|
import { InviteLayout, InviteStatusCard } from '@/app/invite/components'
|
||||||
|
|
||||||
const logger = createLogger('InviteById')
|
const logger = createLogger('InviteById')
|
||||||
|
|
||||||
|
function getErrorMessage(reason: string): string {
|
||||||
|
switch (reason) {
|
||||||
|
case 'missing-token':
|
||||||
|
return 'The invitation link is invalid or missing a required parameter.'
|
||||||
|
case 'invalid-token':
|
||||||
|
return 'The invitation link is invalid or has already been used.'
|
||||||
|
case 'expired':
|
||||||
|
return 'This invitation has expired. Please ask for a new invitation.'
|
||||||
|
case 'already-processed':
|
||||||
|
return 'This invitation has already been accepted or declined.'
|
||||||
|
case 'email-mismatch':
|
||||||
|
return 'This invitation was sent to a different email address. Please log in with the correct account.'
|
||||||
|
case 'workspace-not-found':
|
||||||
|
return 'The workspace associated with this invitation could not be found.'
|
||||||
|
case 'user-not-found':
|
||||||
|
return 'Your user account could not be found. Please try logging out and logging back in.'
|
||||||
|
case 'already-member':
|
||||||
|
return 'You are already a member of this organization or workspace.'
|
||||||
|
case 'already-in-organization':
|
||||||
|
return 'You are already a member of an organization. Leave your current organization before accepting a new invitation.'
|
||||||
|
case 'invalid-invitation':
|
||||||
|
return 'This invitation is invalid or no longer exists.'
|
||||||
|
case 'missing-invitation-id':
|
||||||
|
return 'The invitation link is missing required information. Please use the original invitation link.'
|
||||||
|
case 'server-error':
|
||||||
|
return 'An unexpected error occurred while processing your invitation. Please try again later.'
|
||||||
|
default:
|
||||||
|
return 'An unknown error occurred while processing your invitation.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function Invite() {
|
export default function Invite() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
export function getErrorMessage(reason: string): string {
|
|
||||||
switch (reason) {
|
|
||||||
case 'missing-token':
|
|
||||||
return 'The invitation link is invalid or missing a required parameter.'
|
|
||||||
case 'invalid-token':
|
|
||||||
return 'The invitation link is invalid or has already been used.'
|
|
||||||
case 'expired':
|
|
||||||
return 'This invitation has expired. Please ask for a new invitation.'
|
|
||||||
case 'already-processed':
|
|
||||||
return 'This invitation has already been accepted or declined.'
|
|
||||||
case 'email-mismatch':
|
|
||||||
return 'This invitation was sent to a different email address. Please log in with the correct account.'
|
|
||||||
case 'workspace-not-found':
|
|
||||||
return 'The workspace associated with this invitation could not be found.'
|
|
||||||
case 'user-not-found':
|
|
||||||
return 'Your user account could not be found. Please try logging out and logging back in.'
|
|
||||||
case 'already-member':
|
|
||||||
return 'You are already a member of this organization or workspace.'
|
|
||||||
case 'already-in-organization':
|
|
||||||
return 'You are already a member of an organization. Leave your current organization before accepting a new invitation.'
|
|
||||||
case 'invalid-invitation':
|
|
||||||
return 'This invitation is invalid or no longer exists.'
|
|
||||||
case 'missing-invitation-id':
|
|
||||||
return 'The invitation link is missing required information. Please use the original invitation link.'
|
|
||||||
case 'server-error':
|
|
||||||
return 'An unexpected error occurred while processing your invitation. Please try again later.'
|
|
||||||
default:
|
|
||||||
return 'An unknown error occurred while processing your invitation.'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { formatDistanceToNow } from 'date-fns'
|
import { formatDistanceToNow } from 'date-fns'
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
|
||||||
ChartNoAxesColumn,
|
ChartNoAxesColumn,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Globe,
|
Globe,
|
||||||
@@ -16,6 +15,7 @@ import {
|
|||||||
import { useParams, useRouter, useSearchParams } from 'next/navigation'
|
import { useParams, useRouter, useSearchParams } from 'next/navigation'
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
import {
|
import {
|
||||||
|
Breadcrumb,
|
||||||
Button,
|
Button,
|
||||||
Copy,
|
Copy,
|
||||||
Popover,
|
Popover,
|
||||||
@@ -267,13 +267,13 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleBack = () => {
|
const breadcrumbItems = [
|
||||||
if (isWorkspaceContext) {
|
{
|
||||||
router.back()
|
label: 'Templates',
|
||||||
} else {
|
href: isWorkspaceContext ? `/workspace/${workspaceId}/templates` : '/templates',
|
||||||
router.push('/templates')
|
},
|
||||||
}
|
{ label: template?.name || 'Template' },
|
||||||
}
|
]
|
||||||
/**
|
/**
|
||||||
* Intercepts wheel events over the workflow preview so that the page handles scrolling
|
* Intercepts wheel events over the workflow preview so that the page handles scrolling
|
||||||
* instead of the underlying canvas. We stop propagation in the capture phase to prevent
|
* instead of the underlying canvas. We stop propagation in the capture phase to prevent
|
||||||
@@ -542,24 +542,15 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex min-h-screen flex-col', isWorkspaceContext && 'pl-64')}>
|
<div className={cn('flex flex-col', isWorkspaceContext ? 'h-full flex-1' : 'min-h-screen')}>
|
||||||
<div className='flex flex-1 overflow-hidden'>
|
<div className='flex flex-1 overflow-hidden'>
|
||||||
<div className='flex flex-1 flex-col overflow-auto px-[24px] pt-[24px] pb-[24px]'>
|
<div className='flex flex-1 flex-col overflow-auto px-[24px] pt-[24px] pb-[24px]'>
|
||||||
{/* Top bar with back button */}
|
{/* Breadcrumb navigation */}
|
||||||
<div className='flex items-center justify-between'>
|
<Breadcrumb items={breadcrumbItems} />
|
||||||
{/* Back button */}
|
|
||||||
<button
|
|
||||||
onClick={handleBack}
|
|
||||||
className='flex items-center gap-[6px] font-medium text-[#ADADAD] text-[14px] transition-colors hover:text-white'
|
|
||||||
>
|
|
||||||
<ArrowLeft className='h-[14px] w-[14px]' />
|
|
||||||
<span>More Templates</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Template name and action buttons */}
|
{/* Template name and action buttons */}
|
||||||
<div className='mt-[24px] flex items-center justify-between'>
|
<div className='mt-[14px] flex items-center justify-between'>
|
||||||
<h1 className='font-medium text-[18px]'>{template.name}</h1>
|
<h1 className='font-medium text-[18px] text-[var(--text-primary)]'>{template.name}</h1>
|
||||||
|
|
||||||
{/* Action buttons */}
|
{/* Action buttons */}
|
||||||
<div className='flex items-center gap-[8px]'>
|
<div className='flex items-center gap-[8px]'>
|
||||||
@@ -706,7 +697,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
|||||||
|
|
||||||
{/* Template tagline */}
|
{/* Template tagline */}
|
||||||
{template.details?.tagline && (
|
{template.details?.tagline && (
|
||||||
<p className='mt-[4px] font-medium text-[#888888] text-[14px]'>
|
<p className='mt-[4px] font-medium text-[14px] text-[var(--text-tertiary)]'>
|
||||||
{template.details.tagline}
|
{template.details.tagline}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -718,18 +709,22 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
|||||||
onClick={handleStarToggle}
|
onClick={handleStarToggle}
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-[14px] w-[14px] cursor-pointer transition-colors',
|
'h-[14px] w-[14px] cursor-pointer transition-colors',
|
||||||
template.isStarred ? 'fill-yellow-500 text-yellow-500' : 'text-[#888888]',
|
template.isStarred ? 'fill-yellow-500 text-yellow-500' : 'text-[var(--text-muted)]',
|
||||||
starTemplate.isPending && 'opacity-50'
|
starTemplate.isPending && 'opacity-50'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<span className='font-medium text-[#888888] text-[14px]'>{template.stars || 0}</span>
|
<span className='font-medium text-[14px] text-[var(--text-muted)]'>
|
||||||
|
{template.stars || 0}
|
||||||
|
</span>
|
||||||
|
|
||||||
{/* Users icon and count */}
|
{/* Users icon and count */}
|
||||||
<ChartNoAxesColumn className='h-[16px] w-[16px] text-[#888888]' />
|
<ChartNoAxesColumn className='h-[16px] w-[16px] text-[var(--text-muted)]' />
|
||||||
<span className='font-medium text-[#888888] text-[14px]'>{template.views}</span>
|
<span className='font-medium text-[14px] text-[var(--text-muted)]'>
|
||||||
|
{template.views}
|
||||||
|
</span>
|
||||||
|
|
||||||
{/* Vertical divider */}
|
{/* Vertical divider */}
|
||||||
<div className='mx-[4px] mb-[-1.5px] h-[18px] w-[1.25px] rounded-full bg-[#3A3A3A]' />
|
<div className='mx-[4px] mb-[-1.5px] h-[18px] w-[1.25px] rounded-full bg-[var(--border)]' />
|
||||||
|
|
||||||
{/* Creator profile pic */}
|
{/* Creator profile pic */}
|
||||||
{template.creator?.profileImageUrl ? (
|
{template.creator?.profileImageUrl ? (
|
||||||
@@ -741,13 +736,13 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center rounded-full bg-[#4A4A4A]'>
|
<div className='flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center rounded-full bg-[var(--surface-elevated)]'>
|
||||||
<User className='h-[14px] w-[14px] text-[#888888]' />
|
<User className='h-[14px] w-[14px] text-[var(--text-muted)]' />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Creator name */}
|
{/* Creator name */}
|
||||||
<div className='flex items-center gap-[4px]'>
|
<div className='flex items-center gap-[4px]'>
|
||||||
<span className='font-medium text-[#8B8B8B] text-[14px]'>
|
<span className='font-medium text-[14px] text-[var(--text-muted)]'>
|
||||||
{template.creator?.name || 'Unknown'}
|
{template.creator?.name || 'Unknown'}
|
||||||
</span>
|
</span>
|
||||||
{template.creator?.verified && <VerifiedBadge size='md' />}
|
{template.creator?.verified && <VerifiedBadge size='md' />}
|
||||||
@@ -757,7 +752,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
|||||||
{/* Credentials needed */}
|
{/* Credentials needed */}
|
||||||
{Array.isArray(template.requiredCredentials) &&
|
{Array.isArray(template.requiredCredentials) &&
|
||||||
template.requiredCredentials.length > 0 && (
|
template.requiredCredentials.length > 0 && (
|
||||||
<p className='mt-[12px] font-medium text-[#888888] text-[12px]'>
|
<p className='mt-[12px] font-medium text-[12px] text-[var(--text-muted)]'>
|
||||||
Credentials needed:{' '}
|
Credentials needed:{' '}
|
||||||
{template.requiredCredentials
|
{template.requiredCredentials
|
||||||
.map((cred: CredentialRequirement) => {
|
.map((cred: CredentialRequirement) => {
|
||||||
@@ -783,7 +778,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
|||||||
{/* Last updated overlay */}
|
{/* Last updated overlay */}
|
||||||
{template.updatedAt && (
|
{template.updatedAt && (
|
||||||
<div className='pointer-events-none absolute right-[12px] bottom-[12px] rounded-[4px] bg-[var(--bg)]/80 px-[8px] py-[4px] backdrop-blur-sm'>
|
<div className='pointer-events-none absolute right-[12px] bottom-[12px] rounded-[4px] bg-[var(--bg)]/80 px-[8px] py-[4px] backdrop-blur-sm'>
|
||||||
<span className='font-medium text-[#8B8B8B] text-[12px]'>
|
<span className='font-medium text-[12px] text-[var(--text-muted)]'>
|
||||||
Last updated{' '}
|
Last updated{' '}
|
||||||
{formatDistanceToNow(new Date(template.updatedAt), {
|
{formatDistanceToNow(new Date(template.updatedAt), {
|
||||||
addSuffix: true,
|
addSuffix: true,
|
||||||
@@ -910,8 +905,8 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className='flex h-[48px] w-[48px] flex-shrink-0 items-center justify-center rounded-full bg-[#4A4A4A]'>
|
<div className='flex h-[48px] w-[48px] flex-shrink-0 items-center justify-center rounded-full bg-[var(--surface-elevated)]'>
|
||||||
<User className='h-[24px] w-[24px] text-[#888888]' />
|
<User className='h-[24px] w-[24px] text-[var(--text-muted)]' />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -932,7 +927,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
|||||||
href={template.creator.details.websiteUrl}
|
href={template.creator.details.websiteUrl}
|
||||||
target='_blank'
|
target='_blank'
|
||||||
rel='noopener noreferrer'
|
rel='noopener noreferrer'
|
||||||
className='flex items-center text-[#888888] transition-colors hover:text-[var(--text-primary)]'
|
className='flex items-center text-[var(--text-muted)] transition-colors hover:text-[var(--text-primary)]'
|
||||||
aria-label='Website'
|
aria-label='Website'
|
||||||
>
|
>
|
||||||
<Globe className='h-[14px] w-[14px]' />
|
<Globe className='h-[14px] w-[14px]' />
|
||||||
@@ -943,7 +938,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
|||||||
href={template.creator.details.xUrl}
|
href={template.creator.details.xUrl}
|
||||||
target='_blank'
|
target='_blank'
|
||||||
rel='noopener noreferrer'
|
rel='noopener noreferrer'
|
||||||
className='flex items-center text-[#888888] transition-colors hover:text-[var(--text-primary)]'
|
className='flex items-center text-[var(--text-muted)] transition-colors hover:text-[var(--text-primary)]'
|
||||||
aria-label='X (Twitter)'
|
aria-label='X (Twitter)'
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -960,7 +955,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
|||||||
href={template.creator.details.linkedinUrl}
|
href={template.creator.details.linkedinUrl}
|
||||||
target='_blank'
|
target='_blank'
|
||||||
rel='noopener noreferrer'
|
rel='noopener noreferrer'
|
||||||
className='flex items-center text-[#888888] transition-colors hover:text-[var(--text-primary)]'
|
className='flex items-center text-[var(--text-muted)] transition-colors hover:text-[var(--text-primary)]'
|
||||||
aria-label='LinkedIn'
|
aria-label='LinkedIn'
|
||||||
>
|
>
|
||||||
<Linkedin className='h-[14px] w-[14px]' />
|
<Linkedin className='h-[14px] w-[14px]' />
|
||||||
@@ -969,7 +964,7 @@ export default function TemplateDetails({ isWorkspaceContext = false }: Template
|
|||||||
{template.creator.details?.contactEmail && (
|
{template.creator.details?.contactEmail && (
|
||||||
<a
|
<a
|
||||||
href={`mailto:${template.creator.details.contactEmail}`}
|
href={`mailto:${template.creator.details.contactEmail}`}
|
||||||
className='flex items-center text-[#888888] transition-colors hover:text-[var(--text-primary)]'
|
className='flex items-center text-[var(--text-muted)] transition-colors hover:text-[var(--text-primary)]'
|
||||||
aria-label='Email'
|
aria-label='Email'
|
||||||
>
|
>
|
||||||
<Mail className='h-[14px] w-[14px]' />
|
<Mail className='h-[14px] w-[14px]' />
|
||||||
|
|||||||
@@ -199,7 +199,7 @@ function TemplateCardInner({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={previewRef}
|
ref={previewRef}
|
||||||
className='pointer-events-none h-[180px] w-full overflow-hidden rounded-[6px]'
|
className='pointer-events-none h-[180px] w-full cursor-pointer overflow-hidden rounded-[6px]'
|
||||||
>
|
>
|
||||||
{normalizedState && isInView ? (
|
{normalizedState && isInView ? (
|
||||||
<WorkflowPreview
|
<WorkflowPreview
|
||||||
|
|||||||
@@ -135,15 +135,15 @@ export default function Templates({
|
|||||||
return (
|
return (
|
||||||
<div className='flex h-[100vh] flex-col'>
|
<div className='flex h-[100vh] flex-col'>
|
||||||
<div className='flex flex-1 overflow-hidden'>
|
<div className='flex flex-1 overflow-hidden'>
|
||||||
<div className='flex flex-1 flex-col overflow-auto px-[24px] pt-[24px] pb-[24px]'>
|
<div className='flex flex-1 flex-col overflow-auto px-[24px] pt-[28px] pb-[24px]'>
|
||||||
<div>
|
<div>
|
||||||
<div className='flex items-start gap-[12px]'>
|
<div className='flex items-start gap-[12px]'>
|
||||||
<div className='flex h-[26px] w-[26px] items-center justify-center rounded-[6px] border border-[#7A5F11] bg-[#514215]'>
|
<div className='flex h-[26px] w-[26px] items-center justify-center rounded-[6px] border border-[#1E3A5A] bg-[#0F2A3D]'>
|
||||||
<Layout className='h-[14px] w-[14px] text-[#FBBC04]' />
|
<Layout className='h-[14px] w-[14px] text-[#60A5FA]' />
|
||||||
</div>
|
</div>
|
||||||
<h1 className='font-medium text-[18px]'>Templates</h1>
|
<h1 className='font-medium text-[18px]'>Templates</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className='mt-[10px] font-base text-[#888888] text-[14px]'>
|
<p className='mt-[10px] text-[14px] text-[var(--text-tertiary)]'>
|
||||||
Grab a template and start building, or make one from scratch.
|
Grab a template and start building, or make one from scratch.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -178,15 +178,13 @@ export default function Templates({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='mt-[24px] h-[1px] w-full border-[var(--border)] border-t' />
|
|
||||||
|
|
||||||
<div className='mt-[24px] grid grid-cols-1 gap-x-[20px] gap-y-[40px] md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
|
<div className='mt-[24px] grid grid-cols-1 gap-x-[20px] gap-y-[40px] md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
Array.from({ length: 8 }).map((_, index) => (
|
Array.from({ length: 8 }).map((_, index) => (
|
||||||
<TemplateCardSkeleton key={`skeleton-${index}`} />
|
<TemplateCardSkeleton key={`skeleton-${index}`} />
|
||||||
))
|
))
|
||||||
) : filteredTemplates.length === 0 ? (
|
) : filteredTemplates.length === 0 ? (
|
||||||
<div className='col-span-full flex h-64 items-center justify-center rounded-lg border border-muted-foreground/25 border-dashed bg-muted/20'>
|
<div className='col-span-full flex h-64 items-center justify-center rounded-lg border border-muted-foreground/25 bg-muted/20'>
|
||||||
<div className='text-center'>
|
<div className='text-center'>
|
||||||
<p className='font-medium text-muted-foreground text-sm'>{emptyState.title}</p>
|
<p className='font-medium text-muted-foreground text-sm'>{emptyState.title}</p>
|
||||||
<p className='mt-1 text-muted-foreground/70 text-xs'>{emptyState.description}</p>
|
<p className='mt-1 text-muted-foreground/70 text-xs'>{emptyState.description}</p>
|
||||||
|
|||||||
@@ -2,15 +2,16 @@
|
|||||||
|
|
||||||
import { useRef, useState } from 'react'
|
import { useRef, useState } from 'react'
|
||||||
import { AlertCircle, Loader2 } from 'lucide-react'
|
import { AlertCircle, Loader2 } from 'lucide-react'
|
||||||
import { Button, Textarea } from '@/components/emcn'
|
|
||||||
import {
|
import {
|
||||||
|
Button,
|
||||||
|
Label,
|
||||||
Modal,
|
Modal,
|
||||||
ModalBody,
|
ModalBody,
|
||||||
ModalContent,
|
ModalContent,
|
||||||
ModalFooter,
|
ModalFooter,
|
||||||
ModalHeader,
|
ModalHeader,
|
||||||
} from '@/components/emcn/components/modal/modal'
|
Textarea,
|
||||||
import { Label } from '@/components/ui/label'
|
} from '@/components/emcn'
|
||||||
import { createLogger } from '@/lib/logs/console/logger'
|
import { createLogger } from '@/lib/logs/console/logger'
|
||||||
import type { ChunkData, DocumentData } from '@/stores/knowledge/store'
|
import type { ChunkData, DocumentData } from '@/stores/knowledge/store'
|
||||||
|
|
||||||
@@ -119,24 +120,12 @@ export function CreateChunkModal({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Modal open={open} onOpenChange={handleCloseAttempt}>
|
<Modal open={open} onOpenChange={handleCloseAttempt}>
|
||||||
<ModalContent className='h-[74vh] sm:max-w-[600px]'>
|
<ModalContent size='lg'>
|
||||||
<ModalHeader>Create Chunk</ModalHeader>
|
<ModalHeader>Create Chunk</ModalHeader>
|
||||||
|
|
||||||
<form className='flex min-h-0 flex-1 flex-col'>
|
<form>
|
||||||
<ModalBody>
|
<ModalBody className='!pb-[16px]'>
|
||||||
<div className='space-y-[12px]'>
|
<div className='flex flex-col gap-[8px]'>
|
||||||
{/* Document Info Section */}
|
|
||||||
<div className='flex items-center gap-3 rounded-lg border p-4'>
|
|
||||||
<div className='min-w-0 flex-1'>
|
|
||||||
<p className='font-medium text-[var(--text-primary)] text-sm'>
|
|
||||||
{document?.filename || 'Unknown Document'}
|
|
||||||
</p>
|
|
||||||
<p className='text-[var(--text-tertiary)] text-xs'>
|
|
||||||
Adding chunk to this document
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error Display */}
|
{/* Error Display */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className='flex items-center gap-2 rounded-md border border-[var(--text-error)]/50 bg-[var(--text-error)]/10 p-3'>
|
<div className='flex items-center gap-2 rounded-md border border-[var(--text-error)]/50 bg-[var(--text-error)]/10 p-3'>
|
||||||
@@ -146,22 +135,15 @@ export function CreateChunkModal({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Content Input Section */}
|
{/* Content Input Section */}
|
||||||
<div className='space-y-[8px]'>
|
<Label htmlFor='content'>Chunk</Label>
|
||||||
<Label
|
<Textarea
|
||||||
htmlFor='content'
|
id='content'
|
||||||
className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'
|
value={content}
|
||||||
>
|
onChange={(e) => setContent(e.target.value)}
|
||||||
Chunk Content
|
placeholder='Enter the content for this chunk...'
|
||||||
</Label>
|
rows={12}
|
||||||
<Textarea
|
disabled={isCreating}
|
||||||
id='content'
|
/>
|
||||||
value={content}
|
|
||||||
onChange={(e) => setContent(e.target.value)}
|
|
||||||
placeholder='Enter the content for this chunk...'
|
|
||||||
rows={10}
|
|
||||||
disabled={isCreating}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
|
|
||||||
@@ -196,7 +178,7 @@ export function CreateChunkModal({
|
|||||||
|
|
||||||
{/* Unsaved Changes Alert */}
|
{/* Unsaved Changes Alert */}
|
||||||
<Modal open={showUnsavedChangesAlert} onOpenChange={setShowUnsavedChangesAlert}>
|
<Modal open={showUnsavedChangesAlert} onOpenChange={setShowUnsavedChangesAlert}>
|
||||||
<ModalContent className='w-[400px]'>
|
<ModalContent size='sm'>
|
||||||
<ModalHeader>Discard Changes</ModalHeader>
|
<ModalHeader>Discard Changes</ModalHeader>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Loader2 } from 'lucide-react'
|
|
||||||
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn'
|
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn'
|
||||||
import { Trash } from '@/components/emcn/icons/trash'
|
|
||||||
import { createLogger } from '@/lib/logs/console/logger'
|
import { createLogger } from '@/lib/logs/console/logger'
|
||||||
import type { ChunkData } from '@/stores/knowledge/store'
|
import type { ChunkData } from '@/stores/knowledge/store'
|
||||||
|
|
||||||
@@ -68,7 +66,7 @@ export function DeleteChunkModal({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open={isOpen} onOpenChange={onClose}>
|
<Modal open={isOpen} onOpenChange={onClose}>
|
||||||
<ModalContent className='w-[400px]'>
|
<ModalContent size='sm'>
|
||||||
<ModalHeader>Delete Chunk</ModalHeader>
|
<ModalHeader>Delete Chunk</ModalHeader>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||||
@@ -86,17 +84,7 @@ export function DeleteChunkModal({
|
|||||||
disabled={isDeleting}
|
disabled={isDeleting}
|
||||||
className='!bg-[var(--text-error)] !text-white hover:!bg-[var(--text-error)]/90'
|
className='!bg-[var(--text-error)] !text-white hover:!bg-[var(--text-error)]/90'
|
||||||
>
|
>
|
||||||
{isDeleting ? (
|
{isDeleting ? <>Deleting...</> : <>Delete</>}
|
||||||
<>
|
|
||||||
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
|
||||||
Deleting...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Trash className='mr-2 h-4 w-4' />
|
|
||||||
Delete
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { Search } from 'lucide-react'
|
|
||||||
import { useParams } from 'next/navigation'
|
|
||||||
import { Button } from '@/components/emcn'
|
|
||||||
import {
|
|
||||||
ChunkTableSkeleton,
|
|
||||||
KnowledgeHeader,
|
|
||||||
} from '@/app/workspace/[workspaceId]/knowledge/components'
|
|
||||||
|
|
||||||
interface DocumentLoadingProps {
|
|
||||||
knowledgeBaseId: string
|
|
||||||
knowledgeBaseName: string
|
|
||||||
documentName: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DocumentLoading({
|
|
||||||
knowledgeBaseId,
|
|
||||||
knowledgeBaseName,
|
|
||||||
documentName,
|
|
||||||
}: DocumentLoadingProps) {
|
|
||||||
const params = useParams()
|
|
||||||
const workspaceId = params?.workspaceId as string
|
|
||||||
|
|
||||||
const breadcrumbs = [
|
|
||||||
{
|
|
||||||
id: 'knowledge-root',
|
|
||||||
label: 'Knowledge',
|
|
||||||
href: `/workspace/${workspaceId}/knowledge`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: `knowledge-base-${knowledgeBaseId}`,
|
|
||||||
label: knowledgeBaseName,
|
|
||||||
href: `/workspace/${workspaceId}/knowledge/${knowledgeBaseId}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: `document-${knowledgeBaseId}-${documentName}`,
|
|
||||||
label: documentName,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='flex h-[100vh] flex-col pl-64'>
|
|
||||||
{/* Header with Breadcrumbs */}
|
|
||||||
<KnowledgeHeader breadcrumbs={breadcrumbs} />
|
|
||||||
|
|
||||||
<div className='flex flex-1 overflow-hidden'>
|
|
||||||
<div className='flex flex-1 flex-col overflow-hidden'>
|
|
||||||
{/* Main Content */}
|
|
||||||
<div className='flex-1 overflow-auto'>
|
|
||||||
<div className='px-6 pb-6'>
|
|
||||||
{/* Search Section */}
|
|
||||||
<div className='mb-4 flex items-center justify-between pt-1'>
|
|
||||||
<div className='relative max-w-md'>
|
|
||||||
<div className='relative flex items-center'>
|
|
||||||
<Search className='-translate-y-1/2 pointer-events-none absolute top-1/2 left-3 h-[18px] w-[18px] transform text-muted-foreground' />
|
|
||||||
<input
|
|
||||||
type='text'
|
|
||||||
placeholder='Search chunks...'
|
|
||||||
disabled
|
|
||||||
className='h-10 w-full rounded-md border bg-background px-9 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:font-medium file:text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button disabled variant='primary' className='flex items-center gap-1'>
|
|
||||||
<div className='h-3.5 w-3.5 animate-pulse rounded bg-primary-foreground/30' />
|
|
||||||
<span>Create Chunk</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Table container */}
|
|
||||||
<ChunkTableSkeleton isSidebarCollapsed={false} rowCount={8} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,649 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Loader2 } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Combobox,
|
||||||
|
Input,
|
||||||
|
Label,
|
||||||
|
Modal,
|
||||||
|
ModalBody,
|
||||||
|
ModalContent,
|
||||||
|
ModalFooter,
|
||||||
|
ModalHeader,
|
||||||
|
Trash,
|
||||||
|
} from '@/components/emcn'
|
||||||
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
|
import { MAX_TAG_SLOTS, TAG_SLOTS, type TagSlot } from '@/lib/knowledge/constants'
|
||||||
|
import type { DocumentTag } from '@/lib/knowledge/tags/types'
|
||||||
|
import { createLogger } from '@/lib/logs/console/logger'
|
||||||
|
import {
|
||||||
|
type TagDefinition,
|
||||||
|
useKnowledgeBaseTagDefinitions,
|
||||||
|
} from '@/hooks/use-knowledge-base-tag-definitions'
|
||||||
|
import { useNextAvailableSlot } from '@/hooks/use-next-available-slot'
|
||||||
|
import { type TagDefinitionInput, useTagDefinitions } from '@/hooks/use-tag-definitions'
|
||||||
|
import { type DocumentData, useKnowledgeStore } from '@/stores/knowledge/store'
|
||||||
|
|
||||||
|
const logger = createLogger('DocumentTagsModal')
|
||||||
|
|
||||||
|
interface DocumentTagsModalProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
knowledgeBaseId: string
|
||||||
|
documentId: string
|
||||||
|
documentData: DocumentData | null
|
||||||
|
onDocumentUpdate?: (updates: Record<string, string>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocumentTagsModal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
knowledgeBaseId,
|
||||||
|
documentId,
|
||||||
|
documentData,
|
||||||
|
onDocumentUpdate,
|
||||||
|
}: DocumentTagsModalProps) {
|
||||||
|
const { updateDocument: updateDocumentInStore } = useKnowledgeStore()
|
||||||
|
|
||||||
|
const documentTagHook = useTagDefinitions(knowledgeBaseId, documentId)
|
||||||
|
const kbTagHook = useKnowledgeBaseTagDefinitions(knowledgeBaseId)
|
||||||
|
const { getNextAvailableSlot: getServerNextSlot } = useNextAvailableSlot(knowledgeBaseId)
|
||||||
|
|
||||||
|
const { saveTagDefinitions, tagDefinitions, fetchTagDefinitions } = documentTagHook
|
||||||
|
const { tagDefinitions: kbTagDefinitions, fetchTagDefinitions: refreshTagDefinitions } = kbTagHook
|
||||||
|
|
||||||
|
const [documentTags, setDocumentTags] = useState<DocumentTag[]>([])
|
||||||
|
const [editingTagIndex, setEditingTagIndex] = useState<number | null>(null)
|
||||||
|
const [isCreatingTag, setIsCreatingTag] = useState(false)
|
||||||
|
const [isSavingTag, setIsSavingTag] = useState(false)
|
||||||
|
const [editTagForm, setEditTagForm] = useState({
|
||||||
|
displayName: '',
|
||||||
|
fieldType: 'text',
|
||||||
|
value: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const buildDocumentTags = useCallback((docData: DocumentData, definitions: TagDefinition[]) => {
|
||||||
|
const tags: DocumentTag[] = []
|
||||||
|
|
||||||
|
TAG_SLOTS.forEach((slot) => {
|
||||||
|
const value = docData[slot] as string | null | undefined
|
||||||
|
const definition = definitions.find((def) => def.tagSlot === slot)
|
||||||
|
|
||||||
|
if (value?.trim() && definition) {
|
||||||
|
tags.push({
|
||||||
|
slot,
|
||||||
|
displayName: definition.displayName,
|
||||||
|
fieldType: definition.fieldType,
|
||||||
|
value: value.trim(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return tags
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleTagsChange = useCallback((newTags: DocumentTag[]) => {
|
||||||
|
setDocumentTags(newTags)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSaveDocumentTags = useCallback(
|
||||||
|
async (tagsToSave: DocumentTag[]) => {
|
||||||
|
if (!documentData) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tagData: Record<string, string> = {}
|
||||||
|
|
||||||
|
TAG_SLOTS.forEach((slot) => {
|
||||||
|
tagData[slot] = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
tagsToSave.forEach((tag) => {
|
||||||
|
if (tag.value.trim()) {
|
||||||
|
tagData[tag.slot] = tag.value.trim()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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')
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDocumentInStore(knowledgeBaseId, documentId, tagData)
|
||||||
|
onDocumentUpdate?.(tagData)
|
||||||
|
|
||||||
|
await fetchTagDefinitions()
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating document tags:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
documentData,
|
||||||
|
knowledgeBaseId,
|
||||||
|
documentId,
|
||||||
|
updateDocumentInStore,
|
||||||
|
fetchTagDefinitions,
|
||||||
|
onDocumentUpdate,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleRemoveTag = async (index: number) => {
|
||||||
|
const updatedTags = documentTags.filter((_, i) => i !== index)
|
||||||
|
handleTagsChange(updatedTags)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handleSaveDocumentTags(updatedTags)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error removing tag:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startEditingTag = (index: number) => {
|
||||||
|
const tag = documentTags[index]
|
||||||
|
setEditingTagIndex(index)
|
||||||
|
setEditTagForm({
|
||||||
|
displayName: tag.displayName,
|
||||||
|
fieldType: tag.fieldType,
|
||||||
|
value: tag.value,
|
||||||
|
})
|
||||||
|
setIsCreatingTag(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openTagCreator = () => {
|
||||||
|
setEditingTagIndex(null)
|
||||||
|
setEditTagForm({
|
||||||
|
displayName: '',
|
||||||
|
fieldType: 'text',
|
||||||
|
value: '',
|
||||||
|
})
|
||||||
|
setIsCreatingTag(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelEditingTag = () => {
|
||||||
|
setEditTagForm({
|
||||||
|
displayName: '',
|
||||||
|
fieldType: 'text',
|
||||||
|
value: '',
|
||||||
|
})
|
||||||
|
setEditingTagIndex(null)
|
||||||
|
setIsCreatingTag(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasTagNameConflict = (name: string) => {
|
||||||
|
if (!name.trim()) return false
|
||||||
|
|
||||||
|
return documentTags.some((tag, index) => {
|
||||||
|
if (editingTagIndex !== null && index === editingTagIndex) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return tag.displayName.toLowerCase() === name.trim().toLowerCase()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableDefinitions = kbTagDefinitions.filter((def) => {
|
||||||
|
return !documentTags.some(
|
||||||
|
(tag) => tag.displayName.toLowerCase() === def.displayName.toLowerCase()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const tagNameOptions = availableDefinitions.map((def) => ({
|
||||||
|
label: def.displayName,
|
||||||
|
value: def.displayName,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const saveDocumentTag = async () => {
|
||||||
|
if (!editTagForm.displayName.trim() || !editTagForm.value.trim()) return
|
||||||
|
|
||||||
|
const formData = { ...editTagForm }
|
||||||
|
const currentEditingIndex = editingTagIndex
|
||||||
|
const originalTag = currentEditingIndex !== null ? documentTags[currentEditingIndex] : null
|
||||||
|
setEditingTagIndex(null)
|
||||||
|
setIsCreatingTag(false)
|
||||||
|
setIsSavingTag(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
let targetSlot: string
|
||||||
|
|
||||||
|
if (currentEditingIndex !== null && originalTag) {
|
||||||
|
targetSlot = originalTag.slot
|
||||||
|
} else {
|
||||||
|
const existingDefinition = kbTagDefinitions.find(
|
||||||
|
(def) => def.displayName.toLowerCase() === formData.displayName.toLowerCase()
|
||||||
|
)
|
||||||
|
|
||||||
|
if (existingDefinition) {
|
||||||
|
targetSlot = existingDefinition.tagSlot
|
||||||
|
} else {
|
||||||
|
const serverSlot = await getServerNextSlot(formData.fieldType)
|
||||||
|
if (!serverSlot) {
|
||||||
|
throw new Error(`No available slots for new tag of type '${formData.fieldType}'`)
|
||||||
|
}
|
||||||
|
targetSlot = serverSlot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let updatedTags: DocumentTag[]
|
||||||
|
if (currentEditingIndex !== null) {
|
||||||
|
updatedTags = [...documentTags]
|
||||||
|
updatedTags[currentEditingIndex] = {
|
||||||
|
...updatedTags[currentEditingIndex],
|
||||||
|
displayName: formData.displayName,
|
||||||
|
fieldType: formData.fieldType,
|
||||||
|
value: formData.value,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const newTag: DocumentTag = {
|
||||||
|
slot: targetSlot,
|
||||||
|
displayName: formData.displayName,
|
||||||
|
fieldType: formData.fieldType,
|
||||||
|
value: formData.value,
|
||||||
|
}
|
||||||
|
updatedTags = [...documentTags, newTag]
|
||||||
|
}
|
||||||
|
|
||||||
|
handleTagsChange(updatedTags)
|
||||||
|
|
||||||
|
if (currentEditingIndex !== null && originalTag) {
|
||||||
|
const currentDefinition = kbTagDefinitions.find(
|
||||||
|
(def) => def.displayName.toLowerCase() === originalTag.displayName.toLowerCase()
|
||||||
|
)
|
||||||
|
|
||||||
|
if (currentDefinition) {
|
||||||
|
const updatedDefinition: TagDefinitionInput = {
|
||||||
|
displayName: formData.displayName,
|
||||||
|
fieldType: currentDefinition.fieldType,
|
||||||
|
tagSlot: currentDefinition.tagSlot,
|
||||||
|
_originalDisplayName: originalTag.displayName,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (saveTagDefinitions) {
|
||||||
|
await saveTagDefinitions([updatedDefinition])
|
||||||
|
}
|
||||||
|
await refreshTagDefinitions()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const existingDefinition = kbTagDefinitions.find(
|
||||||
|
(def) => def.displayName.toLowerCase() === formData.displayName.toLowerCase()
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!existingDefinition) {
|
||||||
|
const newDefinition: TagDefinitionInput = {
|
||||||
|
displayName: formData.displayName,
|
||||||
|
fieldType: formData.fieldType,
|
||||||
|
tagSlot: targetSlot as TagSlot,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (saveTagDefinitions) {
|
||||||
|
await saveTagDefinitions([newDefinition])
|
||||||
|
}
|
||||||
|
await refreshTagDefinitions()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await handleSaveDocumentTags(updatedTags)
|
||||||
|
|
||||||
|
setEditTagForm({
|
||||||
|
displayName: '',
|
||||||
|
fieldType: 'text',
|
||||||
|
value: '',
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error saving tag:', error)
|
||||||
|
} finally {
|
||||||
|
setIsSavingTag(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTagEditing = editingTagIndex !== null || isCreatingTag
|
||||||
|
const tagNameConflict = hasTagNameConflict(editTagForm.displayName)
|
||||||
|
|
||||||
|
const hasTagChanges = () => {
|
||||||
|
if (editingTagIndex === null) return true
|
||||||
|
|
||||||
|
const originalTag = documentTags[editingTagIndex]
|
||||||
|
if (!originalTag) return true
|
||||||
|
|
||||||
|
return (
|
||||||
|
originalTag.displayName !== editTagForm.displayName ||
|
||||||
|
originalTag.value !== editTagForm.value ||
|
||||||
|
originalTag.fieldType !== editTagForm.fieldType
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSaveTag =
|
||||||
|
editTagForm.displayName.trim() &&
|
||||||
|
editTagForm.value.trim() &&
|
||||||
|
!tagNameConflict &&
|
||||||
|
hasTagChanges()
|
||||||
|
|
||||||
|
const canAddNewTag = kbTagDefinitions.length < MAX_TAG_SLOTS || availableDefinitions.length > 0
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (documentData && tagDefinitions && !isSavingTag) {
|
||||||
|
const rebuiltTags = buildDocumentTags(documentData, tagDefinitions)
|
||||||
|
setDocumentTags(rebuiltTags)
|
||||||
|
}
|
||||||
|
}, [documentData, tagDefinitions, buildDocumentTags, isSavingTag])
|
||||||
|
|
||||||
|
const handleClose = (openState: boolean) => {
|
||||||
|
if (!openState) {
|
||||||
|
setIsCreatingTag(false)
|
||||||
|
setEditingTagIndex(null)
|
||||||
|
setEditTagForm({
|
||||||
|
displayName: '',
|
||||||
|
fieldType: 'text',
|
||||||
|
value: '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onOpenChange(openState)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onOpenChange={handleClose}>
|
||||||
|
<ModalContent>
|
||||||
|
<ModalHeader>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<span>Document Tags</span>
|
||||||
|
</div>
|
||||||
|
</ModalHeader>
|
||||||
|
|
||||||
|
<ModalBody className='!pb-[16px]'>
|
||||||
|
<div className='min-h-0 flex-1 overflow-y-auto'>
|
||||||
|
<div className='space-y-[8px]'>
|
||||||
|
<Label>
|
||||||
|
Tags{' '}
|
||||||
|
<span className='pl-[6px] text-[var(--text-tertiary)]'>
|
||||||
|
{documentTags.length}/{MAX_TAG_SLOTS} slots used
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
{documentTags.length === 0 && !isCreatingTag && (
|
||||||
|
<div className='rounded-[6px] border p-[16px] text-center'>
|
||||||
|
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||||
|
No tags added yet. Add tags to help organize this document.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{documentTags.map((tag, index) => (
|
||||||
|
<div key={index} className='space-y-[8px]'>
|
||||||
|
<div
|
||||||
|
className='flex cursor-pointer items-center gap-2 rounded-[4px] border p-[8px] hover:bg-[var(--surface-2)]'
|
||||||
|
onClick={() => startEditingTag(index)}
|
||||||
|
>
|
||||||
|
<span className='min-w-0 truncate text-[12px] text-[var(--text-primary)]'>
|
||||||
|
{tag.displayName}
|
||||||
|
</span>
|
||||||
|
<div className='mb-[-1.5px] h-[14px] w-[1.25px] flex-shrink-0 rounded-full bg-[#3A3A3A]' />
|
||||||
|
<span className='min-w-0 flex-1 truncate text-[11px] text-[var(--text-muted)]'>
|
||||||
|
{tag.value}
|
||||||
|
</span>
|
||||||
|
<div className='flex flex-shrink-0 items-center gap-1'>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleRemoveTag(index)
|
||||||
|
}}
|
||||||
|
className='h-4 w-4 p-0 text-[var(--text-muted)] hover:text-[var(--text-error)]'
|
||||||
|
>
|
||||||
|
<Trash className='h-3 w-3' />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editingTagIndex === index && (
|
||||||
|
<div className='space-y-[8px] rounded-[6px] border p-[12px]'>
|
||||||
|
<div className='flex flex-col gap-[8px]'>
|
||||||
|
<Label htmlFor={`tagName-${index}`}>Tag Name</Label>
|
||||||
|
{availableDefinitions.length > 0 ? (
|
||||||
|
<Combobox
|
||||||
|
id={`tagName-${index}`}
|
||||||
|
options={tagNameOptions}
|
||||||
|
value={editTagForm.displayName}
|
||||||
|
selectedValue={editTagForm.displayName}
|
||||||
|
onChange={(value) => {
|
||||||
|
const def = kbTagDefinitions.find(
|
||||||
|
(d) => d.displayName.toLowerCase() === value.toLowerCase()
|
||||||
|
)
|
||||||
|
setEditTagForm({
|
||||||
|
...editTagForm,
|
||||||
|
displayName: value,
|
||||||
|
fieldType: def?.fieldType || 'text',
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
placeholder='Enter or select tag name'
|
||||||
|
editable={true}
|
||||||
|
className={cn(tagNameConflict && 'border-[var(--text-error)]')}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
id={`tagName-${index}`}
|
||||||
|
value={editTagForm.displayName}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditTagForm({ ...editTagForm, displayName: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder='Enter tag name'
|
||||||
|
className={cn(tagNameConflict && 'border-[var(--text-error)]')}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && canSaveTag) {
|
||||||
|
e.preventDefault()
|
||||||
|
saveDocumentTag()
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
cancelEditingTag()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{tagNameConflict && (
|
||||||
|
<span className='text-[11px] text-[var(--text-error)]'>
|
||||||
|
A tag with this name already exists
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Type selector commented out - only "text" type is currently supported
|
||||||
|
<div className='flex flex-col gap-[8px]'>
|
||||||
|
<Label htmlFor={`tagType-${index}`}>Type</Label>
|
||||||
|
<Input id={`tagType-${index}`} value='Text' disabled className='capitalize' />
|
||||||
|
</div>
|
||||||
|
*/}
|
||||||
|
|
||||||
|
<div className='flex flex-col gap-[8px]'>
|
||||||
|
<Label htmlFor={`tagValue-${index}`}>Value</Label>
|
||||||
|
<Input
|
||||||
|
id={`tagValue-${index}`}
|
||||||
|
value={editTagForm.value}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditTagForm({ ...editTagForm, value: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder='Enter tag value'
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && canSaveTag) {
|
||||||
|
e.preventDefault()
|
||||||
|
saveDocumentTag()
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
cancelEditingTag()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex gap-[8px]'>
|
||||||
|
<Button variant='default' onClick={cancelEditingTag} className='flex-1'>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='primary'
|
||||||
|
onClick={saveDocumentTag}
|
||||||
|
className='flex-1'
|
||||||
|
disabled={!canSaveTag}
|
||||||
|
>
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!isTagEditing && (
|
||||||
|
<Button
|
||||||
|
variant='default'
|
||||||
|
onClick={openTagCreator}
|
||||||
|
disabled={!canAddNewTag}
|
||||||
|
className='w-full'
|
||||||
|
>
|
||||||
|
Add Tag
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isCreatingTag && (
|
||||||
|
<div className='space-y-[8px] rounded-[6px] border p-[12px]'>
|
||||||
|
<div className='flex flex-col gap-[8px]'>
|
||||||
|
<Label htmlFor='newTagName'>Tag Name</Label>
|
||||||
|
{tagNameOptions.length > 0 ? (
|
||||||
|
<Combobox
|
||||||
|
id='newTagName'
|
||||||
|
options={tagNameOptions}
|
||||||
|
value={editTagForm.displayName}
|
||||||
|
selectedValue={editTagForm.displayName}
|
||||||
|
onChange={(value) => {
|
||||||
|
const def = kbTagDefinitions.find(
|
||||||
|
(d) => d.displayName.toLowerCase() === value.toLowerCase()
|
||||||
|
)
|
||||||
|
setEditTagForm({
|
||||||
|
...editTagForm,
|
||||||
|
displayName: value,
|
||||||
|
fieldType: def?.fieldType || 'text',
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
placeholder='Enter or select tag name'
|
||||||
|
editable={true}
|
||||||
|
className={cn(tagNameConflict && 'border-[var(--text-error)]')}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
id='newTagName'
|
||||||
|
value={editTagForm.displayName}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditTagForm({ ...editTagForm, displayName: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder='Enter tag name'
|
||||||
|
className={cn(tagNameConflict && 'border-[var(--text-error)]')}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && canSaveTag) {
|
||||||
|
e.preventDefault()
|
||||||
|
saveDocumentTag()
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
cancelEditingTag()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{tagNameConflict && (
|
||||||
|
<span className='text-[11px] text-[var(--text-error)]'>
|
||||||
|
A tag with this name already exists
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Type selector commented out - only "text" type is currently supported
|
||||||
|
<div className='flex flex-col gap-[8px]'>
|
||||||
|
<Label htmlFor='newTagType'>Type</Label>
|
||||||
|
<Input id='newTagType' value='Text' disabled className='capitalize' />
|
||||||
|
</div>
|
||||||
|
*/}
|
||||||
|
|
||||||
|
<div className='flex flex-col gap-[8px]'>
|
||||||
|
<Label htmlFor='newTagValue'>Value</Label>
|
||||||
|
<Input
|
||||||
|
id='newTagValue'
|
||||||
|
value={editTagForm.value}
|
||||||
|
onChange={(e) => setEditTagForm({ ...editTagForm, value: e.target.value })}
|
||||||
|
placeholder='Enter tag value'
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && canSaveTag) {
|
||||||
|
e.preventDefault()
|
||||||
|
saveDocumentTag()
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
cancelEditingTag()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{kbTagDefinitions.length >= MAX_TAG_SLOTS &&
|
||||||
|
!kbTagDefinitions.find(
|
||||||
|
(def) =>
|
||||||
|
def.displayName.toLowerCase() === editTagForm.displayName.toLowerCase()
|
||||||
|
) && (
|
||||||
|
<div className='rounded-[4px] border border-amber-500/50 bg-amber-500/10 p-[8px]'>
|
||||||
|
<p className='text-[11px] text-amber-600 dark:text-amber-400'>
|
||||||
|
Maximum tag definitions reached. You can still use existing tag
|
||||||
|
definitions, but cannot create new ones.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='flex gap-[8px]'>
|
||||||
|
<Button variant='default' onClick={cancelEditingTag} className='flex-1'>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='primary'
|
||||||
|
onClick={saveDocumentTag}
|
||||||
|
className='flex-1'
|
||||||
|
disabled={
|
||||||
|
!canSaveTag ||
|
||||||
|
isSavingTag ||
|
||||||
|
(kbTagDefinitions.length >= MAX_TAG_SLOTS &&
|
||||||
|
!kbTagDefinitions.find(
|
||||||
|
(def) =>
|
||||||
|
def.displayName.toLowerCase() ===
|
||||||
|
editTagForm.displayName.toLowerCase()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isSavingTag ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||||
|
Creating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Create Tag'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalBody>
|
||||||
|
|
||||||
|
<ModalFooter>
|
||||||
|
<Button variant='default' onClick={() => handleClose(false)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||||
import { AlertCircle, ChevronDown, ChevronUp, Loader2, X } from 'lucide-react'
|
import { AlertCircle, ChevronDown, ChevronUp, Loader2, X } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -171,13 +172,15 @@ export function EditChunkModal({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Modal open={isOpen} onOpenChange={handleCloseAttempt}>
|
<Modal open={isOpen} onOpenChange={handleCloseAttempt}>
|
||||||
<ModalContent className='h-[74vh] sm:max-w-[600px]'>
|
<ModalContent size='lg'>
|
||||||
<div className='flex items-center justify-between px-[16px] py-[10px]'>
|
<div className='flex items-center justify-between px-[16px] py-[10px]'>
|
||||||
<div className='flex items-center gap-3'>
|
<DialogPrimitive.Title className='font-medium text-[16px] text-[var(--text-primary)]'>
|
||||||
<span className='font-medium text-[16px] text-[var(--text-primary)]'>Edit Chunk</span>
|
Edit Chunk #{chunk.chunkIndex}
|
||||||
|
</DialogPrimitive.Title>
|
||||||
|
|
||||||
|
<div className='flex flex-shrink-0 items-center gap-[8px]'>
|
||||||
{/* Navigation Controls */}
|
{/* Navigation Controls */}
|
||||||
<div className='flex items-center gap-1'>
|
<div className='flex items-center gap-[6px]'>
|
||||||
<Tooltip.Root>
|
<Tooltip.Root>
|
||||||
<Tooltip.Trigger
|
<Tooltip.Trigger
|
||||||
asChild
|
asChild
|
||||||
@@ -188,9 +191,9 @@ export function EditChunkModal({
|
|||||||
variant='ghost'
|
variant='ghost'
|
||||||
onClick={() => handleNavigate('prev')}
|
onClick={() => handleNavigate('prev')}
|
||||||
disabled={!canNavigatePrev || isNavigating || isSaving}
|
disabled={!canNavigatePrev || isNavigating || isSaving}
|
||||||
className='h-8 w-8 p-0'
|
className='h-[16px] w-[16px] p-0'
|
||||||
>
|
>
|
||||||
<ChevronUp className='h-4 w-4' />
|
<ChevronUp className='h-[16px] w-[16px]' />
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip.Trigger>
|
</Tooltip.Trigger>
|
||||||
<Tooltip.Content side='bottom'>
|
<Tooltip.Content side='bottom'>
|
||||||
@@ -209,9 +212,9 @@ export function EditChunkModal({
|
|||||||
variant='ghost'
|
variant='ghost'
|
||||||
onClick={() => handleNavigate('next')}
|
onClick={() => handleNavigate('next')}
|
||||||
disabled={!canNavigateNext || isNavigating || isSaving}
|
disabled={!canNavigateNext || isNavigating || isSaving}
|
||||||
className='h-8 w-8 p-0'
|
className='h-[16px] w-[16px] p-0'
|
||||||
>
|
>
|
||||||
<ChevronDown className='h-4 w-4' />
|
<ChevronDown className='h-[16px] w-[16px]' />
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip.Trigger>
|
</Tooltip.Trigger>
|
||||||
<Tooltip.Content side='bottom'>
|
<Tooltip.Content side='bottom'>
|
||||||
@@ -222,29 +225,21 @@ export function EditChunkModal({
|
|||||||
</Tooltip.Content>
|
</Tooltip.Content>
|
||||||
</Tooltip.Root>
|
</Tooltip.Root>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button variant='ghost' className='h-[16px] w-[16px] p-0' onClick={handleCloseAttempt}>
|
<Button
|
||||||
<X className='h-[16px] w-[16px]' />
|
variant='ghost'
|
||||||
<span className='sr-only'>Close</span>
|
className='h-[16px] w-[16px] p-0'
|
||||||
</Button>
|
onClick={handleCloseAttempt}
|
||||||
|
>
|
||||||
|
<X className='h-[16px] w-[16px]' />
|
||||||
|
<span className='sr-only'>Close</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form className='flex min-h-0 flex-1 flex-col'>
|
<form>
|
||||||
<ModalBody>
|
<ModalBody className='!pb-[16px]'>
|
||||||
<div className='space-y-[12px]'>
|
<div className='flex flex-col gap-[8px]'>
|
||||||
{/* Document Info Section */}
|
|
||||||
<div className='flex items-center gap-3 rounded-lg border p-4'>
|
|
||||||
<div className='min-w-0 flex-1'>
|
|
||||||
<p className='font-medium text-[var(--text-primary)] text-sm'>
|
|
||||||
{document?.filename || 'Unknown Document'}
|
|
||||||
</p>
|
|
||||||
<p className='text-[var(--text-tertiary)] text-xs'>
|
|
||||||
Editing chunk #{chunk.chunkIndex} • Page {currentPage} of {totalPages}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error Display */}
|
{/* Error Display */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className='flex items-center gap-2 rounded-md border border-[var(--text-error)]/50 bg-[var(--text-error)]/10 p-3'>
|
<div className='flex items-center gap-2 rounded-md border border-[var(--text-error)]/50 bg-[var(--text-error)]/10 p-3'>
|
||||||
@@ -254,25 +249,18 @@ export function EditChunkModal({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Content Input Section */}
|
{/* Content Input Section */}
|
||||||
<div className='space-y-[8px]'>
|
<Label htmlFor='content'>Chunk</Label>
|
||||||
<Label
|
<Textarea
|
||||||
htmlFor='content'
|
id='content'
|
||||||
className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'
|
value={editedContent}
|
||||||
>
|
onChange={(e) => setEditedContent(e.target.value)}
|
||||||
Chunk Content
|
placeholder={
|
||||||
</Label>
|
userPermissions.canEdit ? 'Enter chunk content...' : 'Read-only view'
|
||||||
<Textarea
|
}
|
||||||
id='content'
|
rows={20}
|
||||||
value={editedContent}
|
disabled={isSaving || isNavigating || !userPermissions.canEdit}
|
||||||
onChange={(e) => setEditedContent(e.target.value)}
|
readOnly={!userPermissions.canEdit}
|
||||||
placeholder={
|
/>
|
||||||
userPermissions.canEdit ? 'Enter chunk content...' : 'Read-only view'
|
|
||||||
}
|
|
||||||
rows={10}
|
|
||||||
disabled={isSaving || isNavigating || !userPermissions.canEdit}
|
|
||||||
readOnly={!userPermissions.canEdit}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
|
|
||||||
@@ -298,7 +286,7 @@ export function EditChunkModal({
|
|||||||
Saving...
|
Saving...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
'Save Changes'
|
'Save'
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -309,7 +297,7 @@ export function EditChunkModal({
|
|||||||
|
|
||||||
{/* Unsaved Changes Alert */}
|
{/* Unsaved Changes Alert */}
|
||||||
<Modal open={showUnsavedChangesAlert} onOpenChange={setShowUnsavedChangesAlert}>
|
<Modal open={showUnsavedChangesAlert} onOpenChange={setShowUnsavedChangesAlert}>
|
||||||
<ModalContent className='w-[400px]'>
|
<ModalContent size='sm'>
|
||||||
<ModalHeader>Unsaved Changes</ModalHeader>
|
<ModalHeader>Unsaved Changes</ModalHeader>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export { CreateChunkModal } from './create-chunk-modal/create-chunk-modal'
|
export { CreateChunkModal } from './create-chunk-modal/create-chunk-modal'
|
||||||
export { DeleteChunkModal } from './delete-chunk-modal/delete-chunk-modal'
|
export { DeleteChunkModal } from './delete-chunk-modal/delete-chunk-modal'
|
||||||
export { DocumentLoading } from './document-loading'
|
export { DocumentTagsModal } from './document-tags-modal/document-tags-modal'
|
||||||
export { EditChunkModal } from './edit-chunk-modal/edit-chunk-modal'
|
export { EditChunkModal } from './edit-chunk-modal/edit-chunk-modal'
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,6 @@
|
|||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { Circle, CircleOff, Trash2 } from 'lucide-react'
|
import { Circle, CircleOff } from 'lucide-react'
|
||||||
import { Tooltip } from '@/components/emcn'
|
import { Button, Tooltip, Trash2 } from '@/components/emcn'
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||||
|
|
||||||
@@ -42,23 +41,22 @@ export function ActionBar({
|
|||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
className={cn('-translate-x-1/2 fixed bottom-6 left-1/2 z-50 transform', className)}
|
className={cn('-translate-x-1/2 fixed bottom-6 left-1/2 z-50 transform', className)}
|
||||||
>
|
>
|
||||||
<div className='flex items-center gap-3 rounded-lg border border-gray-200 bg-background px-4 py-2 shadow-sm dark:border-gray-800'>
|
<div className='flex items-center gap-[8px] rounded-[10px] border border-[var(--border-strong)] bg-[var(--surface-1)] p-[8px]'>
|
||||||
<span className='text-gray-500 text-sm'>{selectedCount} selected</span>
|
<span className='px-[4px] text-[13px] text-[var(--text-muted)]'>
|
||||||
|
{selectedCount} selected
|
||||||
|
</span>
|
||||||
|
|
||||||
<div className='h-4 w-px bg-gray-200 dark:bg-gray-800' />
|
<div className='flex items-center gap-[5px]'>
|
||||||
|
|
||||||
<div className='flex items-center gap-1'>
|
|
||||||
{showEnableButton && (
|
{showEnableButton && (
|
||||||
<Tooltip.Root>
|
<Tooltip.Root>
|
||||||
<Tooltip.Trigger asChild>
|
<Tooltip.Trigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
size='sm'
|
|
||||||
onClick={onEnable}
|
onClick={onEnable}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className='text-gray-500 hover:text-gray-700'
|
className='hover:!text-[var(--text-inverse)] h-[28px] w-[28px] rounded-[8px] bg-[var(--surface-9)] p-0 text-[#868686] hover:bg-[var(--brand-secondary)]'
|
||||||
>
|
>
|
||||||
<Circle className='h-4 w-4' />
|
<Circle className='h-[12px] w-[12px]' />
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip.Trigger>
|
</Tooltip.Trigger>
|
||||||
<Tooltip.Content side='top'>
|
<Tooltip.Content side='top'>
|
||||||
@@ -72,12 +70,11 @@ export function ActionBar({
|
|||||||
<Tooltip.Trigger asChild>
|
<Tooltip.Trigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
size='sm'
|
|
||||||
onClick={onDisable}
|
onClick={onDisable}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className='text-gray-500 hover:text-gray-700'
|
className='hover:!text-[var(--text-inverse)] h-[28px] w-[28px] rounded-[8px] bg-[var(--surface-9)] p-0 text-[#868686] hover:bg-[var(--brand-secondary)]'
|
||||||
>
|
>
|
||||||
<CircleOff className='h-4 w-4' />
|
<CircleOff className='h-[12px] w-[12px]' />
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip.Trigger>
|
</Tooltip.Trigger>
|
||||||
<Tooltip.Content side='top'>
|
<Tooltip.Content side='top'>
|
||||||
@@ -91,12 +88,11 @@ export function ActionBar({
|
|||||||
<Tooltip.Trigger asChild>
|
<Tooltip.Trigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant='ghost'
|
variant='ghost'
|
||||||
size='sm'
|
|
||||||
onClick={onDelete}
|
onClick={onDelete}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className='text-gray-500 hover:text-red-600'
|
className='hover:!text-[var(--text-inverse)] h-[28px] w-[28px] rounded-[8px] bg-[var(--surface-9)] p-0 text-[#868686] hover:bg-[var(--brand-secondary)]'
|
||||||
>
|
>
|
||||||
<Trash2 className='h-4 w-4' />
|
<Trash2 className='h-[12px] w-[12px]' />
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip.Trigger>
|
</Tooltip.Trigger>
|
||||||
<Tooltip.Content side='top'>Delete items</Tooltip.Content>
|
<Tooltip.Content side='top'>Delete items</Tooltip.Content>
|
||||||
|
|||||||
@@ -0,0 +1,366 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { AlertCircle, Loader2, RotateCcw, X } from 'lucide-react'
|
||||||
|
import { useParams } from 'next/navigation'
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Label,
|
||||||
|
Modal,
|
||||||
|
ModalBody,
|
||||||
|
ModalContent,
|
||||||
|
ModalFooter,
|
||||||
|
ModalHeader,
|
||||||
|
} from '@/components/emcn'
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||||
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
|
import { createLogger } from '@/lib/logs/console/logger'
|
||||||
|
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'
|
||||||
|
|
||||||
|
const logger = createLogger('AddDocumentsModal')
|
||||||
|
|
||||||
|
interface FileWithPreview extends File {
|
||||||
|
preview: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AddDocumentsModalProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
knowledgeBaseId: string
|
||||||
|
chunkingConfig?: {
|
||||||
|
maxSize: number
|
||||||
|
minSize: number
|
||||||
|
overlap: number
|
||||||
|
}
|
||||||
|
onUploadComplete?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddDocumentsModal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
knowledgeBaseId,
|
||||||
|
chunkingConfig,
|
||||||
|
onUploadComplete,
|
||||||
|
}: AddDocumentsModalProps) {
|
||||||
|
const params = useParams()
|
||||||
|
const workspaceId = params.workspaceId as string
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const [files, setFiles] = useState<FileWithPreview[]>([])
|
||||||
|
const [fileError, setFileError] = useState<string | null>(null)
|
||||||
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
|
const [dragCounter, setDragCounter] = useState(0)
|
||||||
|
const [retryingIndexes, setRetryingIndexes] = useState<Set<number>>(new Set())
|
||||||
|
|
||||||
|
const { isUploading, uploadProgress, uploadFiles, clearError } = useKnowledgeUpload({
|
||||||
|
workspaceId,
|
||||||
|
onUploadComplete: () => {
|
||||||
|
logger.info(`Successfully uploaded ${files.length} files`)
|
||||||
|
onUploadComplete?.()
|
||||||
|
handleClose()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
files.forEach((file) => {
|
||||||
|
if (file.preview) {
|
||||||
|
URL.revokeObjectURL(file.preview)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [files])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setFiles([])
|
||||||
|
setFileError(null)
|
||||||
|
setIsDragging(false)
|
||||||
|
setDragCounter(0)
|
||||||
|
setRetryingIndexes(new Set())
|
||||||
|
clearError()
|
||||||
|
}
|
||||||
|
}, [open, clearError])
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (isUploading) return
|
||||||
|
setFiles([])
|
||||||
|
setFileError(null)
|
||||||
|
clearError()
|
||||||
|
setIsDragging(false)
|
||||||
|
setDragCounter(0)
|
||||||
|
setRetryingIndexes(new Set())
|
||||||
|
onOpenChange(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const processFiles = async (fileList: FileList | File[]) => {
|
||||||
|
setFileError(null)
|
||||||
|
|
||||||
|
if (!fileList || fileList.length === 0) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newFiles: FileWithPreview[] = []
|
||||||
|
let hasError = false
|
||||||
|
|
||||||
|
for (const file of Array.from(fileList)) {
|
||||||
|
const validationError = validateKnowledgeBaseFile(file)
|
||||||
|
if (validationError) {
|
||||||
|
setFileError(validationError)
|
||||||
|
hasError = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileWithPreview = Object.assign(file, {
|
||||||
|
preview: URL.createObjectURL(file),
|
||||||
|
}) as FileWithPreview
|
||||||
|
|
||||||
|
newFiles.push(fileWithPreview)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasError && newFiles.length > 0) {
|
||||||
|
setFiles((prev) => [...prev, ...newFiles])
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error processing files:', error)
|
||||||
|
setFileError('An error occurred while processing files. Please try again.')
|
||||||
|
} finally {
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.target.files) {
|
||||||
|
await processFiles(e.target.files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragEnter = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setDragCounter((prev) => {
|
||||||
|
const newCount = prev + 1
|
||||||
|
if (newCount === 1) {
|
||||||
|
setIsDragging(true)
|
||||||
|
}
|
||||||
|
return newCount
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragLeave = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setDragCounter((prev) => {
|
||||||
|
const newCount = prev - 1
|
||||||
|
if (newCount === 0) {
|
||||||
|
setIsDragging(false)
|
||||||
|
}
|
||||||
|
return newCount
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
e.dataTransfer.dropEffect = 'copy'
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDrop = async (e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setIsDragging(false)
|
||||||
|
setDragCounter(0)
|
||||||
|
|
||||||
|
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||||
|
await processFiles(e.dataTransfer.files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeFile = (index: number) => {
|
||||||
|
setFiles((prev) => {
|
||||||
|
URL.revokeObjectURL(prev[index].preview)
|
||||||
|
return prev.filter((_, i) => i !== index)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRetryFile = async (index: number) => {
|
||||||
|
const fileToRetry = files[index]
|
||||||
|
if (!fileToRetry) return
|
||||||
|
|
||||||
|
setRetryingIndexes((prev) => new Set(prev).add(index))
|
||||||
|
|
||||||
|
try {
|
||||||
|
await uploadFiles([fileToRetry], knowledgeBaseId, {
|
||||||
|
chunkSize: chunkingConfig?.maxSize || 1024,
|
||||||
|
minCharactersPerChunk: chunkingConfig?.minSize || 1,
|
||||||
|
chunkOverlap: chunkingConfig?.overlap || 200,
|
||||||
|
recipe: 'default',
|
||||||
|
})
|
||||||
|
removeFile(index)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error retrying file upload:', error)
|
||||||
|
} finally {
|
||||||
|
setRetryingIndexes((prev) => {
|
||||||
|
const newSet = new Set(prev)
|
||||||
|
newSet.delete(index)
|
||||||
|
return newSet
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpload = async () => {
|
||||||
|
if (files.length === 0) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await uploadFiles(files, knowledgeBaseId, {
|
||||||
|
chunkSize: chunkingConfig?.maxSize || 1024,
|
||||||
|
minCharactersPerChunk: chunkingConfig?.minSize || 1,
|
||||||
|
chunkOverlap: chunkingConfig?.overlap || 200,
|
||||||
|
recipe: 'default',
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error uploading files:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onOpenChange={handleClose}>
|
||||||
|
<ModalContent>
|
||||||
|
<ModalHeader>Add Documents</ModalHeader>
|
||||||
|
|
||||||
|
<ModalBody className='!pb-[16px]'>
|
||||||
|
<div className='min-h-0 flex-1 overflow-y-auto'>
|
||||||
|
<div className='space-y-[12px]'>
|
||||||
|
{fileError && (
|
||||||
|
<Alert variant='destructive'>
|
||||||
|
<AlertCircle className='h-4 w-4' />
|
||||||
|
<AlertTitle>Error</AlertTitle>
|
||||||
|
<AlertDescription>{fileError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='flex flex-col gap-[8px]'>
|
||||||
|
<Label>Upload Documents</Label>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='default'
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
onDragEnter={handleDragEnter}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
className={cn(
|
||||||
|
'!bg-[var(--surface-1)] hover:!bg-[var(--surface-4)] w-full justify-center border border-[var(--c-575757)] border-dashed py-[10px]',
|
||||||
|
isDragging && 'border-[var(--brand-primary-hex)]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type='file'
|
||||||
|
accept={ACCEPT_ATTRIBUTE}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className='hidden'
|
||||||
|
multiple
|
||||||
|
/>
|
||||||
|
<div className='flex flex-col gap-[2px] text-center'>
|
||||||
|
<span className='text-[var(--text-primary)]'>
|
||||||
|
{isDragging ? 'Drop files here' : 'Drop files here or click to browse'}
|
||||||
|
</span>
|
||||||
|
<span className='text-[11px] text-[var(--text-tertiary)]'>
|
||||||
|
PDF, DOC, DOCX, TXT, CSV, XLS, XLSX, MD, PPT, PPTX, HTML (max 100MB each)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{files.length > 0 && (
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<Label>Selected Files</Label>
|
||||||
|
<div className='space-y-2'>
|
||||||
|
{files.map((file, index) => {
|
||||||
|
const fileStatus = uploadProgress.fileStatuses?.[index]
|
||||||
|
const isFailed = fileStatus?.status === 'failed'
|
||||||
|
const isRetrying = retryingIndexes.has(index)
|
||||||
|
const isProcessing = fileStatus?.status === 'uploading' || isRetrying
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className='flex items-center gap-2 rounded-[4px] border p-[8px]'
|
||||||
|
>
|
||||||
|
{isFailed && !isRetrying && (
|
||||||
|
<AlertCircle className='h-4 w-4 flex-shrink-0 text-[var(--text-error)]' />
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'min-w-0 flex-1 truncate text-[12px]',
|
||||||
|
isFailed && !isRetrying && 'text-[var(--text-error)]'
|
||||||
|
)}
|
||||||
|
title={file.name}
|
||||||
|
>
|
||||||
|
{file.name}
|
||||||
|
</span>
|
||||||
|
<span className='flex-shrink-0 text-[11px] text-[var(--text-muted)]'>
|
||||||
|
{formatFileSize(file.size)}
|
||||||
|
</span>
|
||||||
|
<div className='flex flex-shrink-0 items-center gap-1'>
|
||||||
|
{isFailed && !isRetrying && (
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='ghost'
|
||||||
|
className='h-4 w-4 p-0 text-[var(--text-muted)] hover:text-[var(--text-primary)]'
|
||||||
|
onClick={() => handleRetryFile(index)}
|
||||||
|
disabled={isUploading}
|
||||||
|
>
|
||||||
|
<RotateCcw className='h-3.5 w-3.5' />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isProcessing ? (
|
||||||
|
<Loader2 className='h-4 w-4 animate-spin text-[var(--text-muted)]' />
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='ghost'
|
||||||
|
className='h-4 w-4 p-0'
|
||||||
|
onClick={() => removeFile(index)}
|
||||||
|
disabled={isUploading}
|
||||||
|
>
|
||||||
|
<X className='h-3.5 w-3.5' />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalBody>
|
||||||
|
|
||||||
|
<ModalFooter>
|
||||||
|
<Button variant='default' onClick={handleClose} type='button' disabled={isUploading}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='primary'
|
||||||
|
type='button'
|
||||||
|
onClick={handleUpload}
|
||||||
|
disabled={files.length === 0 || isUploading}
|
||||||
|
>
|
||||||
|
{isUploading
|
||||||
|
? uploadProgress.stage === 'uploading'
|
||||||
|
? `Uploading ${uploadProgress.filesCompleted}/${uploadProgress.totalFiles}...`
|
||||||
|
: uploadProgress.stage === 'processing'
|
||||||
|
? 'Processing...'
|
||||||
|
: 'Uploading...'
|
||||||
|
: 'Upload'}
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,485 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { Loader2 } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Label,
|
||||||
|
Modal,
|
||||||
|
ModalBody,
|
||||||
|
ModalContent,
|
||||||
|
ModalFooter,
|
||||||
|
ModalHeader,
|
||||||
|
Trash,
|
||||||
|
} from '@/components/emcn'
|
||||||
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
|
import { MAX_TAG_SLOTS } from '@/lib/knowledge/constants'
|
||||||
|
import { createLogger } from '@/lib/logs/console/logger'
|
||||||
|
import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components'
|
||||||
|
import {
|
||||||
|
type TagDefinition,
|
||||||
|
useKnowledgeBaseTagDefinitions,
|
||||||
|
} from '@/hooks/use-knowledge-base-tag-definitions'
|
||||||
|
|
||||||
|
const logger = createLogger('BaseTagsModal')
|
||||||
|
|
||||||
|
interface TagUsageData {
|
||||||
|
tagName: string
|
||||||
|
tagSlot: string
|
||||||
|
documentCount: number
|
||||||
|
documents: Array<{ id: string; name: string; tagValue: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DocumentListProps {
|
||||||
|
documents: Array<{ id: string; name: string; tagValue: string }>
|
||||||
|
totalCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Displays a list of documents affected by tag operations */
|
||||||
|
function DocumentList({ documents, totalCount }: DocumentListProps) {
|
||||||
|
const displayLimit = 5
|
||||||
|
const hasMore = totalCount > displayLimit
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='rounded-[4px] border'>
|
||||||
|
<div className='max-h-[160px] overflow-y-auto'>
|
||||||
|
{documents.slice(0, displayLimit).map((doc) => {
|
||||||
|
const DocumentIcon = getDocumentIcon('', doc.name)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={doc.id}
|
||||||
|
className='flex items-center gap-[8px] border-b p-[8px] last:border-b-0'
|
||||||
|
>
|
||||||
|
<DocumentIcon className='h-4 w-4 flex-shrink-0 text-[var(--text-muted)]' />
|
||||||
|
<span className='min-w-0 max-w-[120px] truncate text-[12px] text-[var(--text-primary)]'>
|
||||||
|
{doc.name}
|
||||||
|
</span>
|
||||||
|
{doc.tagValue && (
|
||||||
|
<>
|
||||||
|
<div className='mb-[-1.5px] h-[14px] w-[1.25px] flex-shrink-0 rounded-full bg-[#3A3A3A]' />
|
||||||
|
<span className='min-w-0 flex-1 truncate text-[11px] text-[var(--text-muted)]'>
|
||||||
|
{doc.tagValue}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{hasMore && (
|
||||||
|
<div className='p-[8px] text-[11px] text-[var(--text-muted)]'>
|
||||||
|
and {totalCount - displayLimit} more documents
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BaseTagsModalProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
knowledgeBaseId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BaseTagsModal({ open, onOpenChange, knowledgeBaseId }: BaseTagsModalProps) {
|
||||||
|
const { tagDefinitions: kbTagDefinitions, fetchTagDefinitions: refreshTagDefinitions } =
|
||||||
|
useKnowledgeBaseTagDefinitions(knowledgeBaseId)
|
||||||
|
|
||||||
|
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',
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchTagUsage = useCallback(async () => {
|
||||||
|
if (!knowledgeBaseId) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/knowledge/${knowledgeBaseId}/tag-usage`)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch tag usage')
|
||||||
|
}
|
||||||
|
const result = await response.json()
|
||||||
|
if (result.success) {
|
||||||
|
setTagUsageData(result.data)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error fetching tag usage:', error)
|
||||||
|
}
|
||||||
|
}, [knowledgeBaseId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
fetchTagUsage()
|
||||||
|
}
|
||||||
|
}, [open, fetchTagUsage])
|
||||||
|
|
||||||
|
const getTagUsage = (tagSlot: string): TagUsageData => {
|
||||||
|
return (
|
||||||
|
tagUsageData.find((usage) => usage.tagSlot === tagSlot) || {
|
||||||
|
tagName: '',
|
||||||
|
tagSlot,
|
||||||
|
documentCount: 0,
|
||||||
|
documents: [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteTagClick = async (tag: TagDefinition) => {
|
||||||
|
setSelectedTag(tag)
|
||||||
|
await fetchTagUsage()
|
||||||
|
setDeleteTagDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleViewDocuments = async (tag: TagDefinition) => {
|
||||||
|
setSelectedTag(tag)
|
||||||
|
await fetchTagUsage()
|
||||||
|
setViewDocumentsDialogOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openTagCreator = () => {
|
||||||
|
setCreateTagForm({
|
||||||
|
displayName: '',
|
||||||
|
fieldType: 'text',
|
||||||
|
})
|
||||||
|
setIsCreatingTag(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelCreatingTag = () => {
|
||||||
|
setCreateTagForm({
|
||||||
|
displayName: '',
|
||||||
|
fieldType: 'text',
|
||||||
|
})
|
||||||
|
setIsCreatingTag(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasTagNameConflict = (name: string) => {
|
||||||
|
if (!name.trim()) return false
|
||||||
|
return kbTagDefinitions.some(
|
||||||
|
(tag) => tag.displayName.toLowerCase() === name.trim().toLowerCase()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagNameConflict =
|
||||||
|
isCreatingTag && !isSavingTag && hasTagNameConflict(createTagForm.displayName)
|
||||||
|
|
||||||
|
const canSaveTag = () => {
|
||||||
|
return createTagForm.displayName.trim() && !hasTagNameConflict(createTagForm.displayName)
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveTagDefinition = async () => {
|
||||||
|
if (!canSaveTag()) return
|
||||||
|
|
||||||
|
setIsSavingTag(true)
|
||||||
|
try {
|
||||||
|
const usedSlots = new Set(kbTagDefinitions.map((def) => def.tagSlot))
|
||||||
|
const availableSlot = (
|
||||||
|
['tag1', 'tag2', 'tag3', 'tag4', 'tag5', 'tag6', 'tag7'] as const
|
||||||
|
).find((slot) => !usedSlots.has(slot))
|
||||||
|
|
||||||
|
if (!availableSlot) {
|
||||||
|
throw new Error('No available tag slots')
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTagDefinition = {
|
||||||
|
tagSlot: availableSlot,
|
||||||
|
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({
|
||||||
|
displayName: '',
|
||||||
|
fieldType: 'text',
|
||||||
|
})
|
||||||
|
setIsCreatingTag(false)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error creating tag definition:', error)
|
||||||
|
} finally {
|
||||||
|
setIsSavingTag(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDeleteTag = async () => {
|
||||||
|
if (!selectedTag) return
|
||||||
|
|
||||||
|
setIsDeletingTag(true)
|
||||||
|
try {
|
||||||
|
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()])
|
||||||
|
|
||||||
|
setDeleteTagDialogOpen(false)
|
||||||
|
setSelectedTag(null)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error deleting tag definition:', error)
|
||||||
|
} finally {
|
||||||
|
setIsDeletingTag(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedTagUsage = selectedTag ? getTagUsage(selectedTag.tagSlot) : null
|
||||||
|
|
||||||
|
const handleClose = (openState: boolean) => {
|
||||||
|
if (!openState) {
|
||||||
|
setIsCreatingTag(false)
|
||||||
|
setCreateTagForm({
|
||||||
|
displayName: '',
|
||||||
|
fieldType: 'text',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onOpenChange(openState)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal open={open} onOpenChange={handleClose}>
|
||||||
|
<ModalContent>
|
||||||
|
<ModalHeader>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<span>Tags</span>
|
||||||
|
</div>
|
||||||
|
</ModalHeader>
|
||||||
|
|
||||||
|
<ModalBody className='!pb-[16px]'>
|
||||||
|
<div className='min-h-0 flex-1 overflow-y-auto'>
|
||||||
|
<div className='space-y-[8px]'>
|
||||||
|
<Label>
|
||||||
|
Tags:{' '}
|
||||||
|
<span className='pl-[6px] text-[var(--text-tertiary)]'>
|
||||||
|
{kbTagDefinitions.length}/{MAX_TAG_SLOTS} slots used
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
{kbTagDefinitions.length === 0 && !isCreatingTag && (
|
||||||
|
<div className='rounded-[6px] border p-[16px] text-center'>
|
||||||
|
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||||
|
No tag definitions yet. Create your first tag to organize documents.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{kbTagDefinitions.map((tag) => {
|
||||||
|
const usage = getTagUsage(tag.tagSlot)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={tag.id}
|
||||||
|
className='flex cursor-pointer items-center gap-2 rounded-[4px] border p-[8px] hover:bg-[var(--surface-2)]'
|
||||||
|
onClick={() => handleViewDocuments(tag)}
|
||||||
|
>
|
||||||
|
<span className='min-w-0 truncate text-[12px] text-[var(--text-primary)]'>
|
||||||
|
{tag.displayName}
|
||||||
|
</span>
|
||||||
|
<div className='mb-[-1.5px] h-[14px] w-[1.25px] flex-shrink-0 rounded-full bg-[#3A3A3A]' />
|
||||||
|
<span className='min-w-0 flex-1 text-[11px] text-[var(--text-muted)]'>
|
||||||
|
{usage.documentCount} document{usage.documentCount !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
<div className='flex flex-shrink-0 items-center gap-1'>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleDeleteTagClick(tag)
|
||||||
|
}}
|
||||||
|
className='h-4 w-4 p-0 text-[var(--text-muted)] hover:text-[var(--text-error)]'
|
||||||
|
>
|
||||||
|
<Trash className='h-3 w-3' />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{!isCreatingTag && (
|
||||||
|
<Button
|
||||||
|
variant='default'
|
||||||
|
onClick={openTagCreator}
|
||||||
|
disabled={kbTagDefinitions.length >= MAX_TAG_SLOTS}
|
||||||
|
className='w-full'
|
||||||
|
>
|
||||||
|
Add Tag
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isCreatingTag && (
|
||||||
|
<div className='space-y-[8px] rounded-[6px] border p-[12px]'>
|
||||||
|
<div className='flex flex-col gap-[8px]'>
|
||||||
|
<Label htmlFor='tagName'>Tag Name</Label>
|
||||||
|
<Input
|
||||||
|
id='tagName'
|
||||||
|
value={createTagForm.displayName}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCreateTagForm({ ...createTagForm, displayName: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder='Enter tag name'
|
||||||
|
className={cn(tagNameConflict && 'border-[var(--text-error)]')}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && canSaveTag()) {
|
||||||
|
e.preventDefault()
|
||||||
|
saveTagDefinition()
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
cancelCreatingTag()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{tagNameConflict && (
|
||||||
|
<span className='text-[11px] text-[var(--text-error)]'>
|
||||||
|
A tag with this name already exists
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Type selector commented out - only "text" type is currently supported
|
||||||
|
<div className='flex flex-col gap-[8px]'>
|
||||||
|
<Label htmlFor='tagType'>Type</Label>
|
||||||
|
<Input id='tagType' value='Text' disabled className='capitalize' />
|
||||||
|
</div>
|
||||||
|
*/}
|
||||||
|
|
||||||
|
<div className='flex gap-[8px]'>
|
||||||
|
<Button variant='default' onClick={cancelCreatingTag} className='flex-1'>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='primary'
|
||||||
|
onClick={saveTagDefinition}
|
||||||
|
className='flex-1'
|
||||||
|
disabled={!canSaveTag() || isSavingTag}
|
||||||
|
>
|
||||||
|
{isSavingTag ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
||||||
|
Creating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Create Tag'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalBody>
|
||||||
|
|
||||||
|
<ModalFooter>
|
||||||
|
<Button variant='default' onClick={() => handleClose(false)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Delete Tag Confirmation Dialog */}
|
||||||
|
<Modal open={deleteTagDialogOpen} onOpenChange={setDeleteTagDialogOpen}>
|
||||||
|
<ModalContent size='sm'>
|
||||||
|
<ModalHeader>Delete Tag</ModalHeader>
|
||||||
|
<ModalBody className='!pb-[16px]'>
|
||||||
|
<div className='space-y-[8px]'>
|
||||||
|
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||||
|
Are you sure you want to delete the "{selectedTag?.displayName}" tag? This will
|
||||||
|
remove this tag from {selectedTagUsage?.documentCount || 0} document
|
||||||
|
{selectedTagUsage?.documentCount !== 1 ? 's' : ''}.{' '}
|
||||||
|
<span className='text-[var(--text-error)]'>This action cannot be undone.</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{selectedTagUsage && selectedTagUsage.documentCount > 0 && (
|
||||||
|
<div className='flex flex-col gap-[8px]'>
|
||||||
|
<Label>Affected documents:</Label>
|
||||||
|
<DocumentList
|
||||||
|
documents={selectedTagUsage.documents}
|
||||||
|
totalCount={selectedTagUsage.documentCount}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button
|
||||||
|
variant='default'
|
||||||
|
disabled={isDeletingTag}
|
||||||
|
onClick={() => setDeleteTagDialogOpen(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='primary'
|
||||||
|
onClick={confirmDeleteTag}
|
||||||
|
disabled={isDeletingTag}
|
||||||
|
className='!bg-[var(--text-error)] !text-white hover:!bg-[var(--text-error)]/90'
|
||||||
|
>
|
||||||
|
{isDeletingTag ? <>Deleting...</> : 'Delete Tag'}
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* View Documents Dialog */}
|
||||||
|
<Modal open={viewDocumentsDialogOpen} onOpenChange={setViewDocumentsDialogOpen}>
|
||||||
|
<ModalContent size='sm'>
|
||||||
|
<ModalHeader>Documents using "{selectedTag?.displayName}"</ModalHeader>
|
||||||
|
<ModalBody className='!pb-[16px]'>
|
||||||
|
<div className='space-y-[8px]'>
|
||||||
|
<p className='text-[12px] text-[var(--text-tertiary)]'>
|
||||||
|
{selectedTagUsage?.documentCount || 0} document
|
||||||
|
{selectedTagUsage?.documentCount !== 1 ? 's are' : ' is'} currently using this tag
|
||||||
|
definition.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{selectedTagUsage?.documentCount === 0 ? (
|
||||||
|
<div className='rounded-[6px] border p-[16px] text-center'>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DocumentList
|
||||||
|
documents={selectedTagUsage?.documents || []}
|
||||||
|
totalCount={selectedTagUsage?.documentCount || 0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button variant='default' onClick={() => setViewDocumentsDialogOpen(false)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
export { ActionBar } from './action-bar/action-bar'
|
export { ActionBar } from './action-bar/action-bar'
|
||||||
export { KnowledgeBaseLoading } from './knowledge-base-loading/knowledge-base-loading'
|
export { AddDocumentsModal } from './add-documents-modal/add-documents-modal'
|
||||||
export { UploadModal } from './upload-modal/upload-modal'
|
export { BaseTagsModal } from './base-tags-modal/base-tags-modal'
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { Search } from 'lucide-react'
|
|
||||||
import { useParams } from 'next/navigation'
|
|
||||||
import { Button } from '@/components/emcn'
|
|
||||||
import {
|
|
||||||
DocumentTableSkeleton,
|
|
||||||
KnowledgeHeader,
|
|
||||||
} from '@/app/workspace/[workspaceId]/knowledge/components'
|
|
||||||
|
|
||||||
interface KnowledgeBaseLoadingProps {
|
|
||||||
knowledgeBaseName: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function KnowledgeBaseLoading({ knowledgeBaseName }: KnowledgeBaseLoadingProps) {
|
|
||||||
const params = useParams()
|
|
||||||
const workspaceId = params?.workspaceId as string
|
|
||||||
|
|
||||||
const breadcrumbs = [
|
|
||||||
{
|
|
||||||
id: 'knowledge-root',
|
|
||||||
label: 'Knowledge',
|
|
||||||
href: `/workspace/${workspaceId}/knowledge`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'knowledge-base-loading',
|
|
||||||
label: knowledgeBaseName,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='flex h-[100vh] flex-col pl-64'>
|
|
||||||
{/* Fixed Header with Breadcrumbs */}
|
|
||||||
<KnowledgeHeader breadcrumbs={breadcrumbs} />
|
|
||||||
|
|
||||||
<div className='flex flex-1 overflow-hidden'>
|
|
||||||
<div className='flex flex-1 flex-col overflow-hidden'>
|
|
||||||
{/* Main Content */}
|
|
||||||
<div className='flex-1 overflow-auto'>
|
|
||||||
<div className='px-6 pb-6'>
|
|
||||||
{/* Search and Create Section */}
|
|
||||||
<div className='mb-4 flex items-center justify-between pt-1'>
|
|
||||||
<div className='relative max-w-md flex-1'>
|
|
||||||
<div className='relative flex items-center'>
|
|
||||||
<Search className='-translate-y-1/2 pointer-events-none absolute top-1/2 left-3 h-[18px] w-[18px] transform text-muted-foreground' />
|
|
||||||
<input
|
|
||||||
type='text'
|
|
||||||
placeholder='Search documents...'
|
|
||||||
disabled
|
|
||||||
className='h-10 w-full rounded-md border bg-background px-9 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:font-medium file:text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
{/* Add Documents Button - disabled state */}
|
|
||||||
<Button disabled variant='primary' className='flex items-center gap-1'>
|
|
||||||
<div className='h-3.5 w-3.5 animate-pulse rounded bg-primary-foreground/30' />
|
|
||||||
<span>Add Documents</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Table container */}
|
|
||||||
<DocumentTableSkeleton isSidebarCollapsed={false} rowCount={8} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,315 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useRef, useState } from 'react'
|
|
||||||
import { AlertCircle, Check, Loader2, X } from 'lucide-react'
|
|
||||||
import { useParams } from 'next/navigation'
|
|
||||||
import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from '@/components/emcn'
|
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import { Progress } from '@/components/ui/progress'
|
|
||||||
import { createLogger } from '@/lib/logs/console/logger'
|
|
||||||
import { formatFileSize, validateKnowledgeBaseFile } from '@/lib/uploads/utils/file-utils'
|
|
||||||
import { ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
|
|
||||||
import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components'
|
|
||||||
import { useKnowledgeUpload } from '@/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload'
|
|
||||||
|
|
||||||
const logger = createLogger('UploadModal')
|
|
||||||
|
|
||||||
interface FileWithPreview extends File {
|
|
||||||
preview: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UploadModalProps {
|
|
||||||
open: boolean
|
|
||||||
onOpenChange: (open: boolean) => void
|
|
||||||
knowledgeBaseId: string
|
|
||||||
chunkingConfig?: {
|
|
||||||
maxSize: number
|
|
||||||
minSize: number
|
|
||||||
overlap: number
|
|
||||||
}
|
|
||||||
onUploadComplete?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function UploadModal({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
knowledgeBaseId,
|
|
||||||
chunkingConfig,
|
|
||||||
onUploadComplete,
|
|
||||||
}: UploadModalProps) {
|
|
||||||
const params = useParams()
|
|
||||||
const workspaceId = params.workspaceId as string
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
||||||
const [files, setFiles] = useState<FileWithPreview[]>([])
|
|
||||||
|
|
||||||
const [fileError, setFileError] = useState<string | null>(null)
|
|
||||||
const [isDragging, setIsDragging] = useState(false)
|
|
||||||
|
|
||||||
const { isUploading, uploadProgress, uploadError, uploadFiles, clearError } = useKnowledgeUpload({
|
|
||||||
workspaceId,
|
|
||||||
onUploadComplete: () => {
|
|
||||||
logger.info(`Successfully uploaded ${files.length} files`)
|
|
||||||
onUploadComplete?.()
|
|
||||||
handleClose()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
if (isUploading) return // Prevent closing during upload
|
|
||||||
|
|
||||||
setFiles([])
|
|
||||||
setFileError(null)
|
|
||||||
clearError()
|
|
||||||
setIsDragging(false)
|
|
||||||
onOpenChange(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const validateFile = (file: File): string | null => {
|
|
||||||
return validateKnowledgeBaseFile(file)
|
|
||||||
}
|
|
||||||
|
|
||||||
const processFiles = (fileList: FileList | File[]) => {
|
|
||||||
setFileError(null)
|
|
||||||
const newFiles: FileWithPreview[] = []
|
|
||||||
|
|
||||||
for (const file of Array.from(fileList)) {
|
|
||||||
const error = validateFile(file)
|
|
||||||
if (error) {
|
|
||||||
setFileError(error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileWithPreview = Object.assign(file, {
|
|
||||||
preview: URL.createObjectURL(file),
|
|
||||||
})
|
|
||||||
newFiles.push(fileWithPreview)
|
|
||||||
}
|
|
||||||
|
|
||||||
setFiles((prev) => [...prev, ...newFiles])
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeFile = (index: number) => {
|
|
||||||
setFiles((prev) => {
|
|
||||||
const newFiles = [...prev]
|
|
||||||
const removedFile = newFiles.splice(index, 1)[0]
|
|
||||||
if (removedFile.preview) {
|
|
||||||
URL.revokeObjectURL(removedFile.preview)
|
|
||||||
}
|
|
||||||
return newFiles
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
if (e.target.files) {
|
|
||||||
processFiles(e.target.files)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDragOver = (e: React.DragEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
setIsDragging(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDragLeave = (e: React.DragEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
setIsDragging(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDrop = (e: React.DragEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
setIsDragging(false)
|
|
||||||
|
|
||||||
if (e.dataTransfer.files) {
|
|
||||||
processFiles(e.dataTransfer.files)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleUpload = async () => {
|
|
||||||
if (files.length === 0) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
await uploadFiles(files, knowledgeBaseId, {
|
|
||||||
chunkSize: chunkingConfig?.maxSize || 1024,
|
|
||||||
minCharactersPerChunk: chunkingConfig?.minSize || 1,
|
|
||||||
chunkOverlap: chunkingConfig?.overlap || 200,
|
|
||||||
recipe: 'default',
|
|
||||||
})
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error uploading files:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const getFileIcon = (mimeType: string, filename: string) => {
|
|
||||||
const IconComponent = getDocumentIcon(mimeType, filename)
|
|
||||||
return <IconComponent className='h-10 w-8' />
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal open={open} onOpenChange={handleClose}>
|
|
||||||
<ModalContent className='max-h-[95vh] sm:max-w-[600px]'>
|
|
||||||
<ModalHeader>Upload Documents</ModalHeader>
|
|
||||||
|
|
||||||
<ModalBody>
|
|
||||||
<div className='space-y-[12px]'>
|
|
||||||
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
|
|
||||||
Select Files
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
{files.length === 0 ? (
|
|
||||||
<div
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
className={`relative flex cursor-pointer items-center justify-center rounded-lg border-[1.5px] border-dashed p-8 text-center transition-colors ${
|
|
||||||
isDragging
|
|
||||||
? 'border-[var(--brand-primary-hex)] bg-[var(--brand-primary-hex)]/5'
|
|
||||||
: 'border-[var(--c-575757)] hover:border-[var(--text-secondary)]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type='file'
|
|
||||||
accept={ACCEPT_ATTRIBUTE}
|
|
||||||
onChange={handleFileChange}
|
|
||||||
className='hidden'
|
|
||||||
multiple
|
|
||||||
/>
|
|
||||||
<div className='space-y-2'>
|
|
||||||
<p className='font-medium text-[var(--text-primary)] text-sm'>
|
|
||||||
{isDragging ? 'Drop files here!' : 'Drop files here or click to browse'}
|
|
||||||
</p>
|
|
||||||
<p className='text-[var(--text-tertiary)] text-xs'>
|
|
||||||
Supports PDF, DOC, DOCX, TXT, CSV, XLS, XLSX, MD, PPT, PPTX, HTML, JSON, YAML,
|
|
||||||
YML (max 100MB each)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className='space-y-2'>
|
|
||||||
<div
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
className={`cursor-pointer rounded-md border border-dashed p-3 text-center transition-colors ${
|
|
||||||
isDragging
|
|
||||||
? 'border-[var(--brand-primary-hex)] bg-[var(--brand-primary-hex)]/5'
|
|
||||||
: 'border-[var(--c-575757)] hover:border-[var(--text-secondary)]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type='file'
|
|
||||||
accept={ACCEPT_ATTRIBUTE}
|
|
||||||
onChange={handleFileChange}
|
|
||||||
className='hidden'
|
|
||||||
multiple
|
|
||||||
/>
|
|
||||||
<p className='text-[var(--text-primary)] text-sm'>
|
|
||||||
{isDragging ? 'Drop more files here!' : 'Drop more files or click to browse'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='max-h-80 space-y-2 overflow-auto'>
|
|
||||||
{files.map((file, index) => {
|
|
||||||
const fileStatus = uploadProgress.fileStatuses?.[index]
|
|
||||||
const isCurrentlyUploading = fileStatus?.status === 'uploading'
|
|
||||||
const isCompleted = fileStatus?.status === 'completed'
|
|
||||||
const isFailed = fileStatus?.status === 'failed'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={index} className='rounded-md border p-3'>
|
|
||||||
<div className='flex items-center gap-3'>
|
|
||||||
{getFileIcon(file.type, file.name)}
|
|
||||||
<div className='min-w-0 flex-1'>
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
{isCurrentlyUploading && (
|
|
||||||
<Loader2 className='h-4 w-4 animate-spin text-[var(--brand-primary-hex)]' />
|
|
||||||
)}
|
|
||||||
{isCompleted && (
|
|
||||||
<Check className='h-4 w-4 text-[var(--text-success)]' />
|
|
||||||
)}
|
|
||||||
{isFailed && <X className='h-4 w-4 text-[var(--text-error)]' />}
|
|
||||||
<p className='truncate font-medium text-[var(--text-primary)] text-sm'>
|
|
||||||
{file.name}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<p className='text-[var(--text-tertiary)] text-xs'>
|
|
||||||
{formatFileSize(file.size)}
|
|
||||||
</p>
|
|
||||||
{isCurrentlyUploading && (
|
|
||||||
<div className='min-w-0 max-w-32 flex-1'>
|
|
||||||
<Progress value={fileStatus?.progress || 0} className='h-1' />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{isFailed && fileStatus?.error && (
|
|
||||||
<p className='mt-1 text-[var(--text-error)] text-xs'>
|
|
||||||
{fileStatus.error}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type='button'
|
|
||||||
variant='ghost'
|
|
||||||
onClick={() => removeFile(index)}
|
|
||||||
disabled={isUploading}
|
|
||||||
className='h-8 w-8 p-0 text-[var(--text-tertiary)] hover:text-[var(--text-error)]'
|
|
||||||
>
|
|
||||||
<X className='h-4 w-4' />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Show upload error first, then file error only if no upload error */}
|
|
||||||
{uploadError && (
|
|
||||||
<div className='rounded-md border border-[var(--text-error)]/50 bg-[var(--text-error)]/10 px-3 py-2'>
|
|
||||||
<div className='flex items-start gap-2'>
|
|
||||||
<AlertCircle className='mt-0.5 h-4 w-4 shrink-0 text-[var(--text-error)]' />
|
|
||||||
<div className='flex-1 text-[var(--text-error)] text-sm'>
|
|
||||||
{uploadError.message}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{fileError && !uploadError && (
|
|
||||||
<div className='rounded-md border border-[var(--text-error)]/50 bg-[var(--text-error)]/10 px-3 py-2 text-[var(--text-error)] text-sm'>
|
|
||||||
{fileError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ModalBody>
|
|
||||||
|
|
||||||
<ModalFooter>
|
|
||||||
<Button variant='default' onClick={handleClose} disabled={isUploading}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant='primary'
|
|
||||||
onClick={handleUpload}
|
|
||||||
disabled={files.length === 0 || isUploading}
|
|
||||||
>
|
|
||||||
{isUploading
|
|
||||||
? uploadProgress.stage === 'uploading'
|
|
||||||
? `Uploading ${uploadProgress.filesCompleted + 1}/${uploadProgress.totalFiles}...`
|
|
||||||
: uploadProgress.stage === 'processing'
|
|
||||||
? 'Processing...'
|
|
||||||
: 'Uploading...'
|
|
||||||
: `Upload ${files.length} file${files.length !== 1 ? 's' : ''}`}
|
|
||||||
</Button>
|
|
||||||
</ModalFooter>
|
|
||||||
</ModalContent>
|
|
||||||
</Modal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { useParams } from 'next/navigation'
|
||||||
|
import { Badge, DocumentAttachment, Tooltip } from '@/components/emcn'
|
||||||
|
|
||||||
|
interface BaseCardProps {
|
||||||
|
id?: string
|
||||||
|
title: string
|
||||||
|
docCount: number
|
||||||
|
description: string
|
||||||
|
createdAt?: string
|
||||||
|
updatedAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a date string to relative time (e.g., "2h ago", "3d ago")
|
||||||
|
*/
|
||||||
|
function formatRelativeTime(dateString: string): string {
|
||||||
|
const date = new Date(dateString)
|
||||||
|
const now = new Date()
|
||||||
|
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000)
|
||||||
|
|
||||||
|
if (diffInSeconds < 60) {
|
||||||
|
return 'just now'
|
||||||
|
}
|
||||||
|
if (diffInSeconds < 3600) {
|
||||||
|
const minutes = Math.floor(diffInSeconds / 60)
|
||||||
|
return `${minutes}m ago`
|
||||||
|
}
|
||||||
|
if (diffInSeconds < 86400) {
|
||||||
|
const hours = Math.floor(diffInSeconds / 3600)
|
||||||
|
return `${hours}h ago`
|
||||||
|
}
|
||||||
|
if (diffInSeconds < 604800) {
|
||||||
|
const days = Math.floor(diffInSeconds / 86400)
|
||||||
|
return `${days}d ago`
|
||||||
|
}
|
||||||
|
if (diffInSeconds < 2592000) {
|
||||||
|
const weeks = Math.floor(diffInSeconds / 604800)
|
||||||
|
return `${weeks}w ago`
|
||||||
|
}
|
||||||
|
if (diffInSeconds < 31536000) {
|
||||||
|
const months = Math.floor(diffInSeconds / 2592000)
|
||||||
|
return `${months}mo ago`
|
||||||
|
}
|
||||||
|
const years = Math.floor(diffInSeconds / 31536000)
|
||||||
|
return `${years}y ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a date string to absolute format for tooltip display
|
||||||
|
*/
|
||||||
|
function formatAbsoluteDate(dateString: string): string {
|
||||||
|
const date = new Date(dateString)
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skeleton placeholder for a knowledge base card
|
||||||
|
*/
|
||||||
|
export function BaseCardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className='group flex h-full cursor-pointer flex-col gap-[12px] rounded-[4px] bg-[var(--surface-elevated)] px-[8px] py-[6px] transition-colors hover:bg-[var(--surface-5)]'>
|
||||||
|
<div className='flex items-center justify-between gap-[8px]'>
|
||||||
|
<div className='h-[17px] w-[120px] animate-pulse rounded-[4px] bg-[var(--surface-9)]' />
|
||||||
|
<div className='h-[22px] w-[90px] animate-pulse rounded-[4px] bg-[var(--surface-5)]' />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex flex-1 flex-col gap-[8px]'>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<div className='flex items-center gap-[6px]'>
|
||||||
|
<div className='h-[12px] w-[12px] animate-pulse rounded-[2px] bg-[var(--surface-9)]' />
|
||||||
|
<div className='h-[15px] w-[45px] animate-pulse rounded-[4px] bg-[var(--surface-9)]' />
|
||||||
|
</div>
|
||||||
|
<div className='h-[15px] w-[120px] animate-pulse rounded-[4px] bg-[var(--surface-5)]' />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='h-0 w-full border-[var(--divider)] border-t' />
|
||||||
|
|
||||||
|
<div className='flex h-[36px] flex-col gap-[6px]'>
|
||||||
|
<div className='h-[15px] w-full animate-pulse rounded-[4px] bg-[var(--surface-5)]' />
|
||||||
|
<div className='h-[15px] w-[75%] animate-pulse rounded-[4px] bg-[var(--surface-5)]' />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders multiple knowledge base card skeletons as a fragment
|
||||||
|
*/
|
||||||
|
export function BaseCardSkeletonGrid({ count = 8 }: { count?: number }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<BaseCardSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Knowledge base card component displaying overview information
|
||||||
|
*/
|
||||||
|
export function BaseCard({ id, title, docCount, description, updatedAt }: BaseCardProps) {
|
||||||
|
const params = useParams()
|
||||||
|
const workspaceId = params?.workspaceId as string
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams({
|
||||||
|
kbName: title,
|
||||||
|
})
|
||||||
|
const href = `/workspace/${workspaceId}/knowledge/${id || title.toLowerCase().replace(/\s+/g, '-')}?${searchParams.toString()}`
|
||||||
|
|
||||||
|
const shortId = id ? `kb-${id.slice(0, 8)}` : ''
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href={href} prefetch={true} className='h-full'>
|
||||||
|
<div className='group flex h-full cursor-pointer flex-col gap-[12px] rounded-[4px] bg-[var(--surface-elevated)] px-[8px] py-[6px] transition-colors hover:bg-[var(--surface-5)]'>
|
||||||
|
<div className='flex items-center justify-between gap-[8px]'>
|
||||||
|
<h3 className='min-w-0 flex-1 truncate text-[14px] text-[var(--text-primary)]'>
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
{shortId && <Badge className='flex-shrink-0 rounded-[4px] text-[12px]'>{shortId}</Badge>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex flex-1 flex-col gap-[8px]'>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<span className='flex items-center gap-[6px] text-[12px] text-[var(--text-tertiary)]'>
|
||||||
|
<DocumentAttachment className='h-[12px] w-[12px]' />
|
||||||
|
{docCount} {docCount === 1 ? 'doc' : 'docs'}
|
||||||
|
</span>
|
||||||
|
{updatedAt && (
|
||||||
|
<Tooltip.Root>
|
||||||
|
<Tooltip.Trigger asChild>
|
||||||
|
<span className='text-[12px] text-[var(--text-muted)]'>
|
||||||
|
last updated: {formatRelativeTime(updatedAt)}
|
||||||
|
</span>
|
||||||
|
</Tooltip.Trigger>
|
||||||
|
<Tooltip.Content>{formatAbsoluteDate(updatedAt)}</Tooltip.Content>
|
||||||
|
</Tooltip.Root>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='h-0 w-full border-[var(--divider)] border-t' />
|
||||||
|
|
||||||
|
<p className='line-clamp-2 h-[36px] text-[12px] text-[var(--text-tertiary)] leading-[18px]'>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { Check, Copy, LibraryBig } from 'lucide-react'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import { useParams } from 'next/navigation'
|
|
||||||
import { createLogger } from '@/lib/logs/console/logger'
|
|
||||||
|
|
||||||
const logger = createLogger('BaseOverviewComponent')
|
|
||||||
|
|
||||||
interface BaseOverviewProps {
|
|
||||||
id?: string
|
|
||||||
title: string
|
|
||||||
docCount: number
|
|
||||||
description: string
|
|
||||||
createdAt?: string
|
|
||||||
updatedAt?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatRelativeTime(dateString: string): string {
|
|
||||||
const date = new Date(dateString)
|
|
||||||
const now = new Date()
|
|
||||||
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000)
|
|
||||||
|
|
||||||
if (diffInSeconds < 60) {
|
|
||||||
return 'just now'
|
|
||||||
}
|
|
||||||
if (diffInSeconds < 3600) {
|
|
||||||
const minutes = Math.floor(diffInSeconds / 60)
|
|
||||||
return `${minutes}m ago`
|
|
||||||
}
|
|
||||||
if (diffInSeconds < 86400) {
|
|
||||||
const hours = Math.floor(diffInSeconds / 3600)
|
|
||||||
return `${hours}h ago`
|
|
||||||
}
|
|
||||||
if (diffInSeconds < 604800) {
|
|
||||||
const days = Math.floor(diffInSeconds / 86400)
|
|
||||||
return `${days}d ago`
|
|
||||||
}
|
|
||||||
if (diffInSeconds < 2592000) {
|
|
||||||
const weeks = Math.floor(diffInSeconds / 604800)
|
|
||||||
return `${weeks}w ago`
|
|
||||||
}
|
|
||||||
if (diffInSeconds < 31536000) {
|
|
||||||
const months = Math.floor(diffInSeconds / 2592000)
|
|
||||||
return `${months}mo ago`
|
|
||||||
}
|
|
||||||
const years = Math.floor(diffInSeconds / 31536000)
|
|
||||||
return `${years}y ago`
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatAbsoluteDate(dateString: string): string {
|
|
||||||
const date = new Date(dateString)
|
|
||||||
return date.toLocaleDateString('en-US', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function BaseOverview({
|
|
||||||
id,
|
|
||||||
title,
|
|
||||||
docCount,
|
|
||||||
description,
|
|
||||||
createdAt,
|
|
||||||
updatedAt,
|
|
||||||
}: BaseOverviewProps) {
|
|
||||||
const [isCopied, setIsCopied] = useState(false)
|
|
||||||
const params = useParams()
|
|
||||||
const workspaceId = params?.workspaceId as string
|
|
||||||
|
|
||||||
const searchParams = new URLSearchParams({
|
|
||||||
kbName: title,
|
|
||||||
})
|
|
||||||
const href = `/workspace/${workspaceId}/knowledge/${id || title.toLowerCase().replace(/\s+/g, '-')}?${searchParams.toString()}`
|
|
||||||
|
|
||||||
const handleCopy = async (e: React.MouseEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
|
|
||||||
if (id) {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(id)
|
|
||||||
setIsCopied(true)
|
|
||||||
setTimeout(() => setIsCopied(false), 2000)
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('Failed to copy ID:', err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link href={href} prefetch={true}>
|
|
||||||
<div className='group flex cursor-pointer flex-col gap-3 rounded-md border bg-background p-4 transition-colors hover:bg-accent/50'>
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<LibraryBig className='h-4 w-4 flex-shrink-0 text-muted-foreground' />
|
|
||||||
<h3 className='truncate font-medium text-sm leading-tight'>{title}</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='flex flex-col gap-2'>
|
|
||||||
<div className='flex items-center gap-2 text-muted-foreground text-xs'>
|
|
||||||
<span>
|
|
||||||
{docCount} {docCount === 1 ? 'doc' : 'docs'}
|
|
||||||
</span>
|
|
||||||
<span>•</span>
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<span className='truncate font-mono'>{id?.slice(0, 8)}</span>
|
|
||||||
<button
|
|
||||||
onClick={handleCopy}
|
|
||||||
className='flex h-4 w-4 items-center justify-center rounded text-gray-500 hover:bg-gray-100 hover:text-gray-700'
|
|
||||||
>
|
|
||||||
{isCopied ? <Check className='h-3 w-3' /> : <Copy className='h-3 w-3' />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Timestamps */}
|
|
||||||
{(createdAt || updatedAt) && (
|
|
||||||
<div className='flex items-center gap-2 text-muted-foreground text-xs'>
|
|
||||||
{updatedAt && (
|
|
||||||
<span title={`Last updated: ${formatAbsoluteDate(updatedAt)}`}>
|
|
||||||
Updated {formatRelativeTime(updatedAt)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{updatedAt && createdAt && <span>•</span>}
|
|
||||||
{createdAt && (
|
|
||||||
<span title={`Created: ${formatAbsoluteDate(createdAt)}`}>
|
|
||||||
Created {formatRelativeTime(createdAt)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p className='line-clamp-2 overflow-hidden text-muted-foreground text-xs'>
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,536 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
|
import { AlertCircle, Loader2, RotateCcw, X } from 'lucide-react'
|
||||||
|
import { useParams } from 'next/navigation'
|
||||||
|
import { useForm } from 'react-hook-form'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Label,
|
||||||
|
Modal,
|
||||||
|
ModalBody,
|
||||||
|
ModalContent,
|
||||||
|
ModalFooter,
|
||||||
|
ModalHeader,
|
||||||
|
Textarea,
|
||||||
|
} from '@/components/emcn'
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||||
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
|
import { createLogger } from '@/lib/logs/console/logger'
|
||||||
|
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 type { KnowledgeBaseData } from '@/stores/knowledge/store'
|
||||||
|
|
||||||
|
const logger = createLogger('CreateBaseModal')
|
||||||
|
|
||||||
|
interface FileWithPreview extends File {
|
||||||
|
preview: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateBaseModalProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onKnowledgeBaseCreated?: (knowledgeBase: KnowledgeBaseData) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormSchema = z
|
||||||
|
.object({
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'Name is required')
|
||||||
|
.max(100, 'Name must be less than 100 characters')
|
||||||
|
.refine((value) => value.trim().length > 0, 'Name cannot be empty'),
|
||||||
|
description: z.string().max(500, 'Description must be less than 500 characters').optional(),
|
||||||
|
minChunkSize: z
|
||||||
|
.number()
|
||||||
|
.min(1, 'Min chunk size must be at least 1')
|
||||||
|
.max(2000, 'Min chunk size must be less than 2000'),
|
||||||
|
maxChunkSize: z
|
||||||
|
.number()
|
||||||
|
.min(100, 'Max chunk size must be at least 100')
|
||||||
|
.max(4000, 'Max chunk size must be less than 4000'),
|
||||||
|
overlapSize: z
|
||||||
|
.number()
|
||||||
|
.min(0, 'Overlap size must be non-negative')
|
||||||
|
.max(500, 'Overlap size must be less than 500'),
|
||||||
|
})
|
||||||
|
.refine((data) => data.minChunkSize < data.maxChunkSize, {
|
||||||
|
message: 'Min chunk size must be less than max chunk size',
|
||||||
|
path: ['minChunkSize'],
|
||||||
|
})
|
||||||
|
|
||||||
|
type FormValues = z.infer<typeof FormSchema>
|
||||||
|
|
||||||
|
interface SubmitStatus {
|
||||||
|
type: 'success' | 'error'
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateBaseModal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onKnowledgeBaseCreated,
|
||||||
|
}: CreateBaseModalProps) {
|
||||||
|
const params = useParams()
|
||||||
|
const workspaceId = params.workspaceId as string
|
||||||
|
|
||||||
|
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)
|
||||||
|
const [isDragging, setIsDragging] = useState(false)
|
||||||
|
const [dragCounter, setDragCounter] = useState(0)
|
||||||
|
const [retryingIndexes, setRetryingIndexes] = useState<Set<number>>(new Set())
|
||||||
|
|
||||||
|
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const { uploadFiles, isUploading, uploadProgress, clearError } = useKnowledgeUpload({
|
||||||
|
workspaceId,
|
||||||
|
onUploadComplete: (uploadedFiles) => {
|
||||||
|
logger.info(`Successfully uploaded ${uploadedFiles.length} files`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleClose = (open: boolean) => {
|
||||||
|
if (!open) {
|
||||||
|
clearError()
|
||||||
|
}
|
||||||
|
onOpenChange(open)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
files.forEach((file) => {
|
||||||
|
if (file.preview) {
|
||||||
|
URL.revokeObjectURL(file.preview)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [files])
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
watch,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(FormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
minChunkSize: 1,
|
||||||
|
maxChunkSize: 1024,
|
||||||
|
overlapSize: 200,
|
||||||
|
},
|
||||||
|
mode: 'onSubmit',
|
||||||
|
})
|
||||||
|
|
||||||
|
const nameValue = watch('name')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setSubmitStatus(null)
|
||||||
|
setFileError(null)
|
||||||
|
setFiles([])
|
||||||
|
setIsDragging(false)
|
||||||
|
setDragCounter(0)
|
||||||
|
setRetryingIndexes(new Set())
|
||||||
|
reset({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
minChunkSize: 1,
|
||||||
|
maxChunkSize: 1024,
|
||||||
|
overlapSize: 200,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [open, reset])
|
||||||
|
|
||||||
|
const processFiles = async (fileList: FileList | File[]) => {
|
||||||
|
setFileError(null)
|
||||||
|
|
||||||
|
if (!fileList || fileList.length === 0) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newFiles: FileWithPreview[] = []
|
||||||
|
let hasError = false
|
||||||
|
|
||||||
|
for (const file of Array.from(fileList)) {
|
||||||
|
const validationError = validateKnowledgeBaseFile(file)
|
||||||
|
if (validationError) {
|
||||||
|
setFileError(validationError)
|
||||||
|
hasError = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileWithPreview = Object.assign(file, {
|
||||||
|
preview: URL.createObjectURL(file),
|
||||||
|
}) as FileWithPreview
|
||||||
|
|
||||||
|
newFiles.push(fileWithPreview)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasError && newFiles.length > 0) {
|
||||||
|
setFiles((prev) => [...prev, ...newFiles])
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error processing files:', error)
|
||||||
|
setFileError('An error occurred while processing files. Please try again.')
|
||||||
|
} finally {
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.target.files) {
|
||||||
|
await processFiles(e.target.files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragEnter = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setDragCounter((prev) => {
|
||||||
|
const newCount = prev + 1
|
||||||
|
if (newCount === 1) {
|
||||||
|
setIsDragging(true)
|
||||||
|
}
|
||||||
|
return newCount
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragLeave = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setDragCounter((prev) => {
|
||||||
|
const newCount = prev - 1
|
||||||
|
if (newCount === 0) {
|
||||||
|
setIsDragging(false)
|
||||||
|
}
|
||||||
|
return newCount
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
e.dataTransfer.dropEffect = 'copy'
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDrop = async (e: React.DragEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setIsDragging(false)
|
||||||
|
setDragCounter(0)
|
||||||
|
|
||||||
|
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||||
|
await processFiles(e.dataTransfer.files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeFile = (index: number) => {
|
||||||
|
setFiles((prev) => {
|
||||||
|
URL.revokeObjectURL(prev[index].preview)
|
||||||
|
return prev.filter((_, i) => i !== index)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onSubmit = async (data: FormValues) => {
|
||||||
|
setIsSubmitting(true)
|
||||||
|
setSubmitStatus(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const knowledgeBasePayload = {
|
||||||
|
name: data.name,
|
||||||
|
description: data.description || undefined,
|
||||||
|
workspaceId: workspaceId,
|
||||||
|
chunkingConfig: {
|
||||||
|
maxSize: data.maxChunkSize,
|
||||||
|
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) {
|
||||||
|
newKnowledgeBase.docCount = files.length
|
||||||
|
|
||||||
|
if (onKnowledgeBaseCreated) {
|
||||||
|
onKnowledgeBaseCreated(newKnowledgeBase)
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadedFiles = await uploadFiles(files, newKnowledgeBase.id, {
|
||||||
|
chunkSize: data.maxChunkSize,
|
||||||
|
minCharactersPerChunk: data.minChunkSize,
|
||||||
|
chunkOverlap: data.overlapSize,
|
||||||
|
recipe: 'default',
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(`Successfully uploaded ${uploadedFiles.length} files`)
|
||||||
|
logger.info(`Started processing ${uploadedFiles.length} documents in the background`)
|
||||||
|
} else {
|
||||||
|
if (onKnowledgeBaseCreated) {
|
||||||
|
onKnowledgeBaseCreated(newKnowledgeBase)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
files.forEach((file) => URL.revokeObjectURL(file.preview))
|
||||||
|
setFiles([])
|
||||||
|
|
||||||
|
handleClose(false)
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error creating knowledge base:', error)
|
||||||
|
setSubmitStatus({
|
||||||
|
type: 'error',
|
||||||
|
message: error instanceof Error ? error.message : 'An unknown error occurred',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onOpenChange={handleClose}>
|
||||||
|
<ModalContent>
|
||||||
|
<ModalHeader>Create Knowledge Base</ModalHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className='flex min-h-0 flex-1 flex-col'>
|
||||||
|
<ModalBody className='!pb-[16px]'>
|
||||||
|
<div ref={scrollContainerRef} className='min-h-0 flex-1 overflow-y-auto'>
|
||||||
|
<div className='space-y-[12px]'>
|
||||||
|
{submitStatus && submitStatus.type === 'error' && (
|
||||||
|
<Alert variant='destructive'>
|
||||||
|
<AlertCircle className='h-4 w-4' />
|
||||||
|
<AlertTitle>Error</AlertTitle>
|
||||||
|
<AlertDescription>{submitStatus.message}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='flex flex-col gap-[8px]'>
|
||||||
|
<Label htmlFor='name'>Name</Label>
|
||||||
|
<Input
|
||||||
|
id='name'
|
||||||
|
placeholder='Enter knowledge base name'
|
||||||
|
{...register('name')}
|
||||||
|
className={cn(errors.name && 'border-[var(--text-error)]')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex flex-col gap-[8px]'>
|
||||||
|
<Label htmlFor='description'>Description</Label>
|
||||||
|
<Textarea
|
||||||
|
id='description'
|
||||||
|
placeholder='Describe this knowledge base (optional)'
|
||||||
|
rows={3}
|
||||||
|
{...register('description')}
|
||||||
|
className={cn(errors.description && 'border-[var(--text-error)]')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='space-y-[12px] rounded-[6px] bg-[var(--surface-6)] px-[12px] py-[14px]'>
|
||||||
|
<div className='grid grid-cols-2 gap-[12px]'>
|
||||||
|
<div className='flex flex-col gap-[8px]'>
|
||||||
|
<Label htmlFor='minChunkSize'>Min Chunk Size</Label>
|
||||||
|
<Input
|
||||||
|
id='minChunkSize'
|
||||||
|
placeholder='1'
|
||||||
|
{...register('minChunkSize', { valueAsNumber: true })}
|
||||||
|
className={cn(errors.minChunkSize && 'border-[var(--text-error)]')}
|
||||||
|
autoComplete='off'
|
||||||
|
data-form-type='other'
|
||||||
|
name='min-chunk-size'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex flex-col gap-[8px]'>
|
||||||
|
<Label htmlFor='maxChunkSize'>Max Chunk Size</Label>
|
||||||
|
<Input
|
||||||
|
id='maxChunkSize'
|
||||||
|
placeholder='1024'
|
||||||
|
{...register('maxChunkSize', { valueAsNumber: true })}
|
||||||
|
className={cn(errors.maxChunkSize && 'border-[var(--text-error)]')}
|
||||||
|
autoComplete='off'
|
||||||
|
data-form-type='other'
|
||||||
|
name='max-chunk-size'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex flex-col gap-[8px]'>
|
||||||
|
<Label htmlFor='overlapSize'>Overlap Size</Label>
|
||||||
|
<Input
|
||||||
|
id='overlapSize'
|
||||||
|
placeholder='200'
|
||||||
|
{...register('overlapSize', { valueAsNumber: true })}
|
||||||
|
className={cn(errors.overlapSize && 'border-[var(--text-error)]')}
|
||||||
|
autoComplete='off'
|
||||||
|
data-form-type='other'
|
||||||
|
name='overlap-size'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex flex-col gap-[8px]'>
|
||||||
|
<Label>Upload Documents</Label>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='default'
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
onDragEnter={handleDragEnter}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
className={cn(
|
||||||
|
'!bg-[var(--surface-1)] hover:!bg-[var(--surface-4)] w-full justify-center border border-[var(--c-575757)] border-dashed py-[10px]',
|
||||||
|
isDragging && 'border-[var(--brand-primary-hex)]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type='file'
|
||||||
|
accept={ACCEPT_ATTRIBUTE}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className='hidden'
|
||||||
|
multiple
|
||||||
|
/>
|
||||||
|
<div className='flex flex-col gap-[2px] text-center'>
|
||||||
|
<span className='text-[var(--text-primary)]'>
|
||||||
|
{isDragging ? 'Drop files here' : 'Drop files here or click to browse'}
|
||||||
|
</span>
|
||||||
|
<span className='text-[11px] text-[var(--text-tertiary)]'>
|
||||||
|
PDF, DOC, DOCX, TXT, CSV, XLS, XLSX, MD, PPT, PPTX, HTML (max 100MB each)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{files.length > 0 && (
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<Label>Selected Files</Label>
|
||||||
|
<div className='space-y-2'>
|
||||||
|
{files.map((file, index) => {
|
||||||
|
const fileStatus = uploadProgress.fileStatuses?.[index]
|
||||||
|
const isFailed = fileStatus?.status === 'failed'
|
||||||
|
const isRetrying = retryingIndexes.has(index)
|
||||||
|
const isProcessing = fileStatus?.status === 'uploading' || isRetrying
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className='flex items-center gap-2 rounded-[4px] border p-[8px]'
|
||||||
|
>
|
||||||
|
{isFailed && !isRetrying && (
|
||||||
|
<AlertCircle className='h-4 w-4 flex-shrink-0 text-[var(--text-error)]' />
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'min-w-0 flex-1 truncate text-[12px]',
|
||||||
|
isFailed && !isRetrying && 'text-[var(--text-error)]'
|
||||||
|
)}
|
||||||
|
title={file.name}
|
||||||
|
>
|
||||||
|
{file.name}
|
||||||
|
</span>
|
||||||
|
<span className='flex-shrink-0 text-[11px] text-[var(--text-muted)]'>
|
||||||
|
{formatFileSize(file.size)}
|
||||||
|
</span>
|
||||||
|
<div className='flex flex-shrink-0 items-center gap-1'>
|
||||||
|
{isFailed && !isRetrying && (
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='ghost'
|
||||||
|
className='h-4 w-4 p-0 text-[var(--text-muted)] hover:text-[var(--text-primary)]'
|
||||||
|
onClick={() => {
|
||||||
|
setRetryingIndexes((prev) => new Set(prev).add(index))
|
||||||
|
removeFile(index)
|
||||||
|
}}
|
||||||
|
disabled={isUploading}
|
||||||
|
>
|
||||||
|
<RotateCcw className='h-3.5 w-3.5' />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isProcessing ? (
|
||||||
|
<Loader2 className='h-4 w-4 animate-spin text-[var(--text-muted)]' />
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='ghost'
|
||||||
|
className='h-4 w-4 p-0'
|
||||||
|
onClick={() => removeFile(index)}
|
||||||
|
disabled={isUploading}
|
||||||
|
>
|
||||||
|
<X className='h-3.5 w-3.5' />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{fileError && (
|
||||||
|
<Alert variant='destructive'>
|
||||||
|
<AlertCircle className='h-4 w-4' />
|
||||||
|
<AlertTitle>Error</AlertTitle>
|
||||||
|
<AlertDescription>{fileError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalBody>
|
||||||
|
|
||||||
|
<ModalFooter>
|
||||||
|
<Button
|
||||||
|
variant='default'
|
||||||
|
onClick={() => handleClose(false)}
|
||||||
|
type='button'
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant='primary' type='submit' disabled={isSubmitting || !nameValue?.trim()}>
|
||||||
|
{isSubmitting
|
||||||
|
? isUploading
|
||||||
|
? uploadProgress.stage === 'uploading'
|
||||||
|
? `Uploading ${uploadProgress.filesCompleted}/${uploadProgress.totalFiles}...`
|
||||||
|
: uploadProgress.stage === 'processing'
|
||||||
|
? 'Processing...'
|
||||||
|
: 'Creating...'
|
||||||
|
: 'Creating...'
|
||||||
|
: 'Create'}
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</form>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,650 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react'
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
|
||||||
import { AlertCircle, Check, Loader2, X } from 'lucide-react'
|
|
||||||
import { useParams } from 'next/navigation'
|
|
||||||
import { useForm } from 'react-hook-form'
|
|
||||||
import { z } from 'zod'
|
|
||||||
import { Button, Input, Label, Textarea } from '@/components/emcn'
|
|
||||||
import {
|
|
||||||
Modal,
|
|
||||||
ModalBody,
|
|
||||||
ModalContent,
|
|
||||||
ModalFooter,
|
|
||||||
ModalHeader,
|
|
||||||
} from '@/components/emcn/components/modal/modal'
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
|
||||||
import { Progress } from '@/components/ui/progress'
|
|
||||||
import { createLogger } from '@/lib/logs/console/logger'
|
|
||||||
import { formatFileSize, validateKnowledgeBaseFile } from '@/lib/uploads/utils/file-utils'
|
|
||||||
import { ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
|
|
||||||
import { getDocumentIcon } from '@/app/workspace/[workspaceId]/knowledge/components'
|
|
||||||
import { useKnowledgeUpload } from '@/app/workspace/[workspaceId]/knowledge/hooks/use-knowledge-upload'
|
|
||||||
import type { KnowledgeBaseData } from '@/stores/knowledge/store'
|
|
||||||
|
|
||||||
const logger = createLogger('CreateModal')
|
|
||||||
|
|
||||||
interface FileWithPreview extends File {
|
|
||||||
preview: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CreateModalProps {
|
|
||||||
open: boolean
|
|
||||||
onOpenChange: (open: boolean) => void
|
|
||||||
onKnowledgeBaseCreated?: (knowledgeBase: KnowledgeBaseData) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const FormSchema = z
|
|
||||||
.object({
|
|
||||||
name: z
|
|
||||||
.string()
|
|
||||||
.min(1, 'Name is required')
|
|
||||||
.max(100, 'Name must be less than 100 characters')
|
|
||||||
.refine((value) => value.trim().length > 0, 'Name cannot be empty'),
|
|
||||||
description: z.string().max(500, 'Description must be less than 500 characters').optional(),
|
|
||||||
minChunkSize: z
|
|
||||||
.number()
|
|
||||||
.min(1, 'Min chunk size must be at least 1')
|
|
||||||
.max(2000, 'Min chunk size must be less than 2000'),
|
|
||||||
maxChunkSize: z
|
|
||||||
.number()
|
|
||||||
.min(100, 'Max chunk size must be at least 100')
|
|
||||||
.max(4000, 'Max chunk size must be less than 4000'),
|
|
||||||
overlapSize: z
|
|
||||||
.number()
|
|
||||||
.min(0, 'Overlap size must be non-negative')
|
|
||||||
.max(500, 'Overlap size must be less than 500'),
|
|
||||||
})
|
|
||||||
.refine((data) => data.minChunkSize < data.maxChunkSize, {
|
|
||||||
message: 'Min chunk size must be less than max chunk size',
|
|
||||||
path: ['minChunkSize'],
|
|
||||||
})
|
|
||||||
|
|
||||||
type FormValues = z.infer<typeof FormSchema>
|
|
||||||
|
|
||||||
interface SubmitStatus {
|
|
||||||
type: 'success' | 'error'
|
|
||||||
message: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CreateModal({ open, onOpenChange, onKnowledgeBaseCreated }: CreateModalProps) {
|
|
||||||
const params = useParams()
|
|
||||||
const workspaceId = params.workspaceId as string
|
|
||||||
|
|
||||||
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)
|
|
||||||
const [isDragging, setIsDragging] = useState(false)
|
|
||||||
const [dragCounter, setDragCounter] = useState(0) // Track drag events to handle nested elements
|
|
||||||
|
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
|
||||||
const dropZoneRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
const { uploadFiles, isUploading, uploadProgress, uploadError, clearError } = useKnowledgeUpload({
|
|
||||||
workspaceId,
|
|
||||||
onUploadComplete: (uploadedFiles) => {
|
|
||||||
logger.info(`Successfully uploaded ${uploadedFiles.length} files`)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleClose = (open: boolean) => {
|
|
||||||
if (!open) {
|
|
||||||
clearError()
|
|
||||||
}
|
|
||||||
onOpenChange(open)
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
files.forEach((file) => {
|
|
||||||
if (file.preview) {
|
|
||||||
URL.revokeObjectURL(file.preview)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [files])
|
|
||||||
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
handleSubmit,
|
|
||||||
reset,
|
|
||||||
watch,
|
|
||||||
formState: { errors },
|
|
||||||
} = useForm<FormValues>({
|
|
||||||
resolver: zodResolver(FormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
minChunkSize: 1,
|
|
||||||
maxChunkSize: 1024,
|
|
||||||
overlapSize: 200,
|
|
||||||
},
|
|
||||||
mode: 'onSubmit',
|
|
||||||
})
|
|
||||||
|
|
||||||
const nameValue = watch('name')
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) {
|
|
||||||
setSubmitStatus(null)
|
|
||||||
setFileError(null)
|
|
||||||
setFiles([])
|
|
||||||
setIsDragging(false)
|
|
||||||
setDragCounter(0)
|
|
||||||
reset({
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
minChunkSize: 1,
|
|
||||||
maxChunkSize: 1024,
|
|
||||||
overlapSize: 200,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [open, reset])
|
|
||||||
|
|
||||||
const processFiles = async (fileList: FileList | File[]) => {
|
|
||||||
setFileError(null)
|
|
||||||
|
|
||||||
if (!fileList || fileList.length === 0) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
const newFiles: FileWithPreview[] = []
|
|
||||||
let hasError = false
|
|
||||||
|
|
||||||
for (const file of Array.from(fileList)) {
|
|
||||||
const validationError = validateKnowledgeBaseFile(file)
|
|
||||||
if (validationError) {
|
|
||||||
setFileError(validationError)
|
|
||||||
hasError = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const fileWithPreview = Object.assign(file, {
|
|
||||||
preview: URL.createObjectURL(file),
|
|
||||||
}) as FileWithPreview
|
|
||||||
|
|
||||||
newFiles.push(fileWithPreview)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasError && newFiles.length > 0) {
|
|
||||||
setFiles((prev) => [...prev, ...newFiles])
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error processing files:', error)
|
|
||||||
setFileError('An error occurred while processing files. Please try again.')
|
|
||||||
} finally {
|
|
||||||
if (fileInputRef.current) {
|
|
||||||
fileInputRef.current.value = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
if (e.target.files) {
|
|
||||||
await processFiles(e.target.files)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDragEnter = (e: React.DragEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
setDragCounter((prev) => {
|
|
||||||
const newCount = prev + 1
|
|
||||||
if (newCount === 1) {
|
|
||||||
setIsDragging(true)
|
|
||||||
}
|
|
||||||
return newCount
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDragLeave = (e: React.DragEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
setDragCounter((prev) => {
|
|
||||||
const newCount = prev - 1
|
|
||||||
if (newCount === 0) {
|
|
||||||
setIsDragging(false)
|
|
||||||
}
|
|
||||||
return newCount
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDragOver = (e: React.DragEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
e.dataTransfer.dropEffect = 'copy'
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDrop = async (e: React.DragEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
setIsDragging(false)
|
|
||||||
setDragCounter(0)
|
|
||||||
|
|
||||||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
|
||||||
await processFiles(e.dataTransfer.files)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeFile = (index: number) => {
|
|
||||||
setFiles((prev) => {
|
|
||||||
URL.revokeObjectURL(prev[index].preview)
|
|
||||||
return prev.filter((_, i) => i !== index)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const getFileIcon = (mimeType: string, filename: string) => {
|
|
||||||
const IconComponent = getDocumentIcon(mimeType, filename)
|
|
||||||
return <IconComponent className='h-10 w-8' />
|
|
||||||
}
|
|
||||||
|
|
||||||
const onSubmit = async (data: FormValues) => {
|
|
||||||
setIsSubmitting(true)
|
|
||||||
setSubmitStatus(null)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const knowledgeBasePayload = {
|
|
||||||
name: data.name,
|
|
||||||
description: data.description || undefined,
|
|
||||||
workspaceId: workspaceId,
|
|
||||||
chunkingConfig: {
|
|
||||||
maxSize: data.maxChunkSize,
|
|
||||||
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) {
|
|
||||||
newKnowledgeBase.docCount = files.length
|
|
||||||
|
|
||||||
if (onKnowledgeBaseCreated) {
|
|
||||||
onKnowledgeBaseCreated(newKnowledgeBase)
|
|
||||||
}
|
|
||||||
|
|
||||||
const uploadedFiles = await uploadFiles(files, newKnowledgeBase.id, {
|
|
||||||
chunkSize: data.maxChunkSize,
|
|
||||||
minCharactersPerChunk: data.minChunkSize,
|
|
||||||
chunkOverlap: data.overlapSize,
|
|
||||||
recipe: 'default',
|
|
||||||
})
|
|
||||||
|
|
||||||
logger.info(`Successfully uploaded ${uploadedFiles.length} files`)
|
|
||||||
logger.info(`Started processing ${uploadedFiles.length} documents in the background`)
|
|
||||||
} else {
|
|
||||||
if (onKnowledgeBaseCreated) {
|
|
||||||
onKnowledgeBaseCreated(newKnowledgeBase)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
files.forEach((file) => URL.revokeObjectURL(file.preview))
|
|
||||||
setFiles([])
|
|
||||||
|
|
||||||
handleClose(false)
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error creating knowledge base:', error)
|
|
||||||
setSubmitStatus({
|
|
||||||
type: 'error',
|
|
||||||
message: error instanceof Error ? error.message : 'An unknown error occurred',
|
|
||||||
})
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal open={open} onOpenChange={handleClose}>
|
|
||||||
<ModalContent className='h-[78vh] max-h-[95vh] sm:max-w-[750px]'>
|
|
||||||
<ModalHeader>Create Knowledge Base</ModalHeader>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className='flex min-h-0 flex-1 flex-col'>
|
|
||||||
<ModalBody>
|
|
||||||
<div ref={scrollContainerRef} className='space-y-[12px]'>
|
|
||||||
{/* Show upload error first, then submit error only if no upload error */}
|
|
||||||
{uploadError && (
|
|
||||||
<Alert variant='destructive'>
|
|
||||||
<AlertCircle className='h-4 w-4' />
|
|
||||||
<AlertTitle>Upload Error</AlertTitle>
|
|
||||||
<AlertDescription>{uploadError.message}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{submitStatus && submitStatus.type === 'error' && !uploadError && (
|
|
||||||
<Alert variant='destructive'>
|
|
||||||
<AlertCircle className='h-4 w-4' />
|
|
||||||
<AlertTitle>Error</AlertTitle>
|
|
||||||
<AlertDescription>{submitStatus.message}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Form Fields Section */}
|
|
||||||
<div className='space-y-[8px]'>
|
|
||||||
<Label
|
|
||||||
htmlFor='name'
|
|
||||||
className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'
|
|
||||||
>
|
|
||||||
Name *
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id='name'
|
|
||||||
placeholder='Enter knowledge base name'
|
|
||||||
{...register('name')}
|
|
||||||
className={errors.name ? 'border-[var(--text-error)]' : ''}
|
|
||||||
/>
|
|
||||||
{errors.name && (
|
|
||||||
<p className='mt-1 text-[var(--text-error)] text-sm'>{errors.name.message}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='space-y-[8px]'>
|
|
||||||
<Label
|
|
||||||
htmlFor='description'
|
|
||||||
className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'
|
|
||||||
>
|
|
||||||
Description
|
|
||||||
</Label>
|
|
||||||
<Textarea
|
|
||||||
id='description'
|
|
||||||
placeholder='Describe what this knowledge base contains (optional)'
|
|
||||||
rows={3}
|
|
||||||
{...register('description')}
|
|
||||||
className={errors.description ? 'border-[var(--text-error)]' : ''}
|
|
||||||
/>
|
|
||||||
{errors.description && (
|
|
||||||
<p className='mt-1 text-[var(--text-error)] text-sm'>
|
|
||||||
{errors.description.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Chunk Configuration Section */}
|
|
||||||
<div className='space-y-[12px] rounded-lg border p-5'>
|
|
||||||
<h3 className='font-medium text-[var(--text-primary)] text-sm'>
|
|
||||||
Chunking Configuration
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{/* Min and Max Chunk Size Row */}
|
|
||||||
<div className='grid grid-cols-2 gap-4'>
|
|
||||||
<div className='space-y-[8px]'>
|
|
||||||
<Label
|
|
||||||
htmlFor='minChunkSize'
|
|
||||||
className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'
|
|
||||||
>
|
|
||||||
Min Chunk Size
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id='minChunkSize'
|
|
||||||
type='number'
|
|
||||||
placeholder='1'
|
|
||||||
{...register('minChunkSize', { valueAsNumber: true })}
|
|
||||||
className={errors.minChunkSize ? 'border-[var(--text-error)]' : ''}
|
|
||||||
autoComplete='off'
|
|
||||||
data-form-type='other'
|
|
||||||
name='min-chunk-size'
|
|
||||||
/>
|
|
||||||
{errors.minChunkSize && (
|
|
||||||
<p className='mt-1 text-[var(--text-error)] text-xs'>
|
|
||||||
{errors.minChunkSize.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='space-y-[8px]'>
|
|
||||||
<Label
|
|
||||||
htmlFor='maxChunkSize'
|
|
||||||
className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'
|
|
||||||
>
|
|
||||||
Max Chunk Size
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id='maxChunkSize'
|
|
||||||
type='number'
|
|
||||||
placeholder='1024'
|
|
||||||
{...register('maxChunkSize', { valueAsNumber: true })}
|
|
||||||
className={errors.maxChunkSize ? 'border-[var(--text-error)]' : ''}
|
|
||||||
autoComplete='off'
|
|
||||||
data-form-type='other'
|
|
||||||
name='max-chunk-size'
|
|
||||||
/>
|
|
||||||
{errors.maxChunkSize && (
|
|
||||||
<p className='mt-1 text-[var(--text-error)] text-xs'>
|
|
||||||
{errors.maxChunkSize.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Overlap Size */}
|
|
||||||
<div className='space-y-[8px]'>
|
|
||||||
<Label
|
|
||||||
htmlFor='overlapSize'
|
|
||||||
className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'
|
|
||||||
>
|
|
||||||
Overlap Size
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id='overlapSize'
|
|
||||||
type='number'
|
|
||||||
placeholder='200'
|
|
||||||
{...register('overlapSize', { valueAsNumber: true })}
|
|
||||||
className={errors.overlapSize ? 'border-[var(--text-error)]' : ''}
|
|
||||||
autoComplete='off'
|
|
||||||
data-form-type='other'
|
|
||||||
name='overlap-size'
|
|
||||||
/>
|
|
||||||
{errors.overlapSize && (
|
|
||||||
<p className='mt-1 text-[var(--text-error)] text-xs'>
|
|
||||||
{errors.overlapSize.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className='text-[var(--text-tertiary)] text-xs'>
|
|
||||||
Configure how documents are split into chunks for processing. Smaller chunks
|
|
||||||
provide more precise retrieval but may lose context.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* File Upload Section */}
|
|
||||||
<div className='space-y-[12px]'>
|
|
||||||
<Label className='mb-[6.5px] block pl-[2px] font-medium text-[13px] text-[var(--text-primary)]'>
|
|
||||||
Upload Documents
|
|
||||||
</Label>
|
|
||||||
{files.length === 0 ? (
|
|
||||||
<div
|
|
||||||
ref={dropZoneRef}
|
|
||||||
onDragEnter={handleDragEnter}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
className={`relative flex cursor-pointer items-center justify-center rounded-lg border-[1.5px] border-dashed py-8 text-center transition-all duration-200 ${
|
|
||||||
isDragging
|
|
||||||
? 'border-[var(--brand-primary-hex)] bg-[var(--brand-primary-hex)]/5'
|
|
||||||
: 'border-[var(--c-575757)] hover:border-[var(--text-secondary)]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type='file'
|
|
||||||
accept={ACCEPT_ATTRIBUTE}
|
|
||||||
onChange={handleFileChange}
|
|
||||||
className='hidden'
|
|
||||||
multiple
|
|
||||||
/>
|
|
||||||
<div className='flex flex-col items-center gap-3'>
|
|
||||||
<div className='space-y-1'>
|
|
||||||
<p
|
|
||||||
className={`font-medium text-[var(--text-primary)] text-sm transition-colors duration-200 ${
|
|
||||||
isDragging ? 'text-[var(--brand-primary-hex)]' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isDragging ? 'Drop files here!' : 'Drop files here or click to browse'}
|
|
||||||
</p>
|
|
||||||
<p className='text-[var(--text-tertiary)] text-xs'>
|
|
||||||
Supports PDF, DOC, DOCX, TXT, CSV, XLS, XLSX, MD, PPT, PPTX, HTML, JSON,
|
|
||||||
YAML, YML (max 100MB each)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className='space-y-2'>
|
|
||||||
{/* Compact drop area at top of file list */}
|
|
||||||
<div
|
|
||||||
ref={dropZoneRef}
|
|
||||||
onDragEnter={handleDragEnter}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
className={`cursor-pointer rounded-md border border-dashed p-3 text-center transition-all duration-200 ${
|
|
||||||
isDragging
|
|
||||||
? 'border-[var(--brand-primary-hex)] bg-[var(--brand-primary-hex)]/5'
|
|
||||||
: 'border-[var(--c-575757)] hover:border-[var(--text-secondary)]'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type='file'
|
|
||||||
accept={ACCEPT_ATTRIBUTE}
|
|
||||||
onChange={handleFileChange}
|
|
||||||
className='hidden'
|
|
||||||
multiple
|
|
||||||
/>
|
|
||||||
<div className='flex items-center justify-center gap-2'>
|
|
||||||
<div>
|
|
||||||
<p
|
|
||||||
className={`font-medium text-[var(--text-primary)] text-sm transition-colors duration-200 ${
|
|
||||||
isDragging ? 'text-[var(--brand-primary-hex)]' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isDragging
|
|
||||||
? 'Drop more files here!'
|
|
||||||
: 'Drop more files or click to browse'}
|
|
||||||
</p>
|
|
||||||
<p className='text-[var(--text-tertiary)] text-xs'>
|
|
||||||
PDF, DOC, DOCX, TXT, CSV, XLS, XLSX, MD, PPT, PPTX, HTML (max 100MB
|
|
||||||
each)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* File list */}
|
|
||||||
<div className='space-y-2'>
|
|
||||||
{files.map((file, index) => {
|
|
||||||
const fileStatus = uploadProgress.fileStatuses?.[index]
|
|
||||||
const isCurrentlyUploading = fileStatus?.status === 'uploading'
|
|
||||||
const isCompleted = fileStatus?.status === 'completed'
|
|
||||||
const isFailed = fileStatus?.status === 'failed'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className='flex items-center gap-3 rounded-md border p-3'
|
|
||||||
>
|
|
||||||
{getFileIcon(file.type, file.name)}
|
|
||||||
<div className='min-w-0 flex-1'>
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
{isCurrentlyUploading && (
|
|
||||||
<Loader2 className='h-4 w-4 animate-spin text-[var(--brand-primary-hex)]' />
|
|
||||||
)}
|
|
||||||
{isCompleted && (
|
|
||||||
<Check className='h-4 w-4 text-[var(--text-success)]' />
|
|
||||||
)}
|
|
||||||
{isFailed && <X className='h-4 w-4 text-[var(--text-error)]' />}
|
|
||||||
<p className='truncate font-medium text-[var(--text-primary)] text-sm'>
|
|
||||||
{file.name}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<p className='text-[var(--text-tertiary)] text-xs'>
|
|
||||||
{formatFileSize(file.size)}
|
|
||||||
</p>
|
|
||||||
{isCurrentlyUploading && (
|
|
||||||
<div className='min-w-0 max-w-32 flex-1'>
|
|
||||||
<Progress value={fileStatus?.progress || 0} className='h-1' />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{isFailed && fileStatus?.error && (
|
|
||||||
<p className='mt-1 text-[var(--text-error)] text-xs'>
|
|
||||||
{fileStatus.error}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type='button'
|
|
||||||
variant='ghost'
|
|
||||||
onClick={() => removeFile(index)}
|
|
||||||
disabled={isUploading}
|
|
||||||
className='h-8 w-8 p-0 text-[var(--text-tertiary)] hover:text-[var(--text-error)]'
|
|
||||||
>
|
|
||||||
<X className='h-4 w-4' />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{fileError && (
|
|
||||||
<Alert variant='destructive'>
|
|
||||||
<AlertCircle className='h-4 w-4' />
|
|
||||||
<AlertTitle>Error</AlertTitle>
|
|
||||||
<AlertDescription>{fileError}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ModalBody>
|
|
||||||
|
|
||||||
<ModalFooter>
|
|
||||||
<Button
|
|
||||||
variant='default'
|
|
||||||
onClick={() => handleClose(false)}
|
|
||||||
type='button'
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button variant='primary' type='submit' disabled={isSubmitting || !nameValue?.trim()}>
|
|
||||||
{isSubmitting
|
|
||||||
? isUploading
|
|
||||||
? uploadProgress.stage === 'uploading'
|
|
||||||
? `Uploading ${uploadProgress.filesCompleted}/${uploadProgress.totalFiles}...`
|
|
||||||
: uploadProgress.stage === 'processing'
|
|
||||||
? 'Processing...'
|
|
||||||
: 'Creating...'
|
|
||||||
: 'Creating...'
|
|
||||||
: 'Create Knowledge Base'}
|
|
||||||
</Button>
|
|
||||||
</ModalFooter>
|
|
||||||
</form>
|
|
||||||
</ModalContent>
|
|
||||||
</Modal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,480 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { ChevronDown, Info, Plus, X } from 'lucide-react'
|
|
||||||
import { Tooltip } from '@/components/emcn'
|
|
||||||
import {
|
|
||||||
Badge,
|
|
||||||
Button,
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
Input,
|
|
||||||
Label,
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui'
|
|
||||||
import { MAX_TAG_SLOTS, type TagSlot } from '@/lib/knowledge/constants'
|
|
||||||
import { createLogger } from '@/lib/logs/console/logger'
|
|
||||||
import { useKnowledgeBaseTagDefinitions } from '@/hooks/use-knowledge-base-tag-definitions'
|
|
||||||
import { useNextAvailableSlot } from '@/hooks/use-next-available-slot'
|
|
||||||
import { type TagDefinitionInput, useTagDefinitions } from '@/hooks/use-tag-definitions'
|
|
||||||
|
|
||||||
const logger = createLogger('DocumentTagEntry')
|
|
||||||
|
|
||||||
export interface DocumentTag {
|
|
||||||
slot: string
|
|
||||||
displayName: string
|
|
||||||
fieldType: string
|
|
||||||
value: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DocumentTagEntryProps {
|
|
||||||
tags: DocumentTag[]
|
|
||||||
onTagsChange: (newTags: DocumentTag[]) => void
|
|
||||||
disabled?: boolean
|
|
||||||
knowledgeBaseId: string
|
|
||||||
documentId: string | null
|
|
||||||
onSave?: (tagsToSave: DocumentTag[]) => Promise<void>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DocumentTagEntry({
|
|
||||||
tags,
|
|
||||||
onTagsChange,
|
|
||||||
disabled = false,
|
|
||||||
knowledgeBaseId,
|
|
||||||
documentId,
|
|
||||||
onSave,
|
|
||||||
}: DocumentTagEntryProps) {
|
|
||||||
// Use different hooks based on whether we have a documentId
|
|
||||||
const documentTagHook = useTagDefinitions(knowledgeBaseId, documentId)
|
|
||||||
const kbTagHook = useKnowledgeBaseTagDefinitions(knowledgeBaseId)
|
|
||||||
const { getNextAvailableSlot: getServerNextSlot } = useNextAvailableSlot(knowledgeBaseId)
|
|
||||||
|
|
||||||
// Use the document-level hook since we have documentId
|
|
||||||
const { saveTagDefinitions } = documentTagHook
|
|
||||||
const { tagDefinitions: kbTagDefinitions, fetchTagDefinitions: refreshTagDefinitions } = kbTagHook
|
|
||||||
|
|
||||||
// Modal state for tag editing
|
|
||||||
const [editingTagIndex, setEditingTagIndex] = useState<number | null>(null)
|
|
||||||
const [modalOpen, setModalOpen] = useState(false)
|
|
||||||
const [editForm, setEditForm] = useState({
|
|
||||||
displayName: '',
|
|
||||||
fieldType: 'text',
|
|
||||||
value: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleRemoveTag = async (index: number) => {
|
|
||||||
const updatedTags = tags.filter((_, i) => i !== index)
|
|
||||||
onTagsChange(updatedTags)
|
|
||||||
|
|
||||||
// Persist the changes if onSave is provided
|
|
||||||
if (onSave) {
|
|
||||||
try {
|
|
||||||
await onSave(updatedTags)
|
|
||||||
} catch (error) {
|
|
||||||
// Handle error silently - the UI will show the optimistic update
|
|
||||||
// but the user can retry if needed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open modal to edit tag
|
|
||||||
const openTagModal = (index: number) => {
|
|
||||||
const tag = tags[index]
|
|
||||||
setEditingTagIndex(index)
|
|
||||||
setEditForm({
|
|
||||||
displayName: tag.displayName,
|
|
||||||
fieldType: tag.fieldType,
|
|
||||||
value: tag.value,
|
|
||||||
})
|
|
||||||
setModalOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open modal to create new tag
|
|
||||||
const openNewTagModal = () => {
|
|
||||||
setEditingTagIndex(null)
|
|
||||||
setEditForm({
|
|
||||||
displayName: '',
|
|
||||||
fieldType: 'text',
|
|
||||||
value: '',
|
|
||||||
})
|
|
||||||
setModalOpen(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save tag from modal
|
|
||||||
const saveTagFromModal = async () => {
|
|
||||||
if (!editForm.displayName.trim() || !editForm.value.trim()) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
let targetSlot: string
|
|
||||||
|
|
||||||
if (editingTagIndex !== null) {
|
|
||||||
// EDIT MODE: Editing existing tag - use existing slot
|
|
||||||
targetSlot = tags[editingTagIndex].slot
|
|
||||||
} else {
|
|
||||||
// CREATE MODE: Check if using existing definition or creating new one
|
|
||||||
const existingDefinition = kbTagDefinitions.find(
|
|
||||||
(def) => def.displayName.toLowerCase() === editForm.displayName.toLowerCase()
|
|
||||||
)
|
|
||||||
|
|
||||||
if (existingDefinition) {
|
|
||||||
// Using existing definition - use its slot
|
|
||||||
targetSlot = existingDefinition.tagSlot
|
|
||||||
} else {
|
|
||||||
// Creating new definition - get next available slot from server
|
|
||||||
const serverSlot = await getServerNextSlot(editForm.fieldType)
|
|
||||||
if (!serverSlot) {
|
|
||||||
throw new Error(`No available slots for new tag of type '${editForm.fieldType}'`)
|
|
||||||
}
|
|
||||||
targetSlot = serverSlot
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the tags array
|
|
||||||
if (editingTagIndex !== null) {
|
|
||||||
// Editing existing tag
|
|
||||||
const updatedTags = [...tags]
|
|
||||||
updatedTags[editingTagIndex] = {
|
|
||||||
...updatedTags[editingTagIndex],
|
|
||||||
displayName: editForm.displayName,
|
|
||||||
fieldType: editForm.fieldType,
|
|
||||||
value: editForm.value,
|
|
||||||
}
|
|
||||||
onTagsChange(updatedTags)
|
|
||||||
} else {
|
|
||||||
// Creating new tag
|
|
||||||
const newTag: DocumentTag = {
|
|
||||||
slot: targetSlot,
|
|
||||||
displayName: editForm.displayName,
|
|
||||||
fieldType: editForm.fieldType,
|
|
||||||
value: editForm.value,
|
|
||||||
}
|
|
||||||
const newTags = [...tags, newTag]
|
|
||||||
onTagsChange(newTags)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle tag definition creation/update based on edit mode
|
|
||||||
if (editingTagIndex !== null) {
|
|
||||||
// EDIT MODE: Always update existing definition, never create new slots
|
|
||||||
const currentTag = tags[editingTagIndex]
|
|
||||||
const currentDefinition = kbTagDefinitions.find(
|
|
||||||
(def) => def.displayName.toLowerCase() === currentTag.displayName.toLowerCase()
|
|
||||||
)
|
|
||||||
|
|
||||||
if (currentDefinition) {
|
|
||||||
const updatedDefinition: TagDefinitionInput = {
|
|
||||||
displayName: editForm.displayName,
|
|
||||||
fieldType: currentDefinition.fieldType, // Keep existing field type (can't change in edit mode)
|
|
||||||
tagSlot: currentDefinition.tagSlot, // Keep existing slot
|
|
||||||
_originalDisplayName: currentTag.displayName, // Tell server which definition to update
|
|
||||||
}
|
|
||||||
|
|
||||||
if (saveTagDefinitions) {
|
|
||||||
await saveTagDefinitions([updatedDefinition])
|
|
||||||
} else {
|
|
||||||
throw new Error('Cannot save tag definitions without a document ID')
|
|
||||||
}
|
|
||||||
await refreshTagDefinitions()
|
|
||||||
|
|
||||||
// Update the document tag's display name
|
|
||||||
const updatedTags = [...tags]
|
|
||||||
updatedTags[editingTagIndex] = {
|
|
||||||
...currentTag,
|
|
||||||
displayName: editForm.displayName,
|
|
||||||
fieldType: currentDefinition.fieldType,
|
|
||||||
}
|
|
||||||
onTagsChange(updatedTags)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// CREATE MODE: Adding new tag
|
|
||||||
const existingDefinition = kbTagDefinitions.find(
|
|
||||||
(def) => def.displayName.toLowerCase() === editForm.displayName.toLowerCase()
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!existingDefinition) {
|
|
||||||
// Create new definition
|
|
||||||
const newDefinition: TagDefinitionInput = {
|
|
||||||
displayName: editForm.displayName,
|
|
||||||
fieldType: editForm.fieldType,
|
|
||||||
tagSlot: targetSlot as TagSlot,
|
|
||||||
}
|
|
||||||
|
|
||||||
if (saveTagDefinitions) {
|
|
||||||
await saveTagDefinitions([newDefinition])
|
|
||||||
} else {
|
|
||||||
throw new Error('Cannot save tag definitions without a document ID')
|
|
||||||
}
|
|
||||||
await refreshTagDefinitions()
|
|
||||||
}
|
|
||||||
// If existingDefinition exists, use it (no server update needed)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save the actual document tags if onSave is provided
|
|
||||||
if (onSave) {
|
|
||||||
const updatedTags =
|
|
||||||
editingTagIndex !== null
|
|
||||||
? tags.map((tag, index) =>
|
|
||||||
index === editingTagIndex
|
|
||||||
? {
|
|
||||||
...tag,
|
|
||||||
displayName: editForm.displayName,
|
|
||||||
fieldType: editForm.fieldType,
|
|
||||||
value: editForm.value,
|
|
||||||
}
|
|
||||||
: tag
|
|
||||||
)
|
|
||||||
: [
|
|
||||||
...tags,
|
|
||||||
{
|
|
||||||
slot: targetSlot,
|
|
||||||
displayName: editForm.displayName,
|
|
||||||
fieldType: editForm.fieldType,
|
|
||||||
value: editForm.value,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
await onSave(updatedTags)
|
|
||||||
}
|
|
||||||
|
|
||||||
setModalOpen(false)
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error saving tag:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter available tag definitions based on context
|
|
||||||
const availableDefinitions = kbTagDefinitions.filter((def) => {
|
|
||||||
if (editingTagIndex !== null) {
|
|
||||||
// When editing, exclude only other used tag names (not the current one being edited)
|
|
||||||
return !tags.some(
|
|
||||||
(tag, index) =>
|
|
||||||
index !== editingTagIndex &&
|
|
||||||
tag.displayName.toLowerCase() === def.displayName.toLowerCase()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// When creating new, exclude all already used tag names
|
|
||||||
return !tags.some((tag) => tag.displayName.toLowerCase() === def.displayName.toLowerCase())
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='space-y-4'>
|
|
||||||
<div className='flex items-center justify-between'>
|
|
||||||
<h3 className='font-medium text-sm'>Document Tags</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tags as Badges */}
|
|
||||||
<div className='flex flex-wrap gap-2'>
|
|
||||||
{tags.map((tag, index) => (
|
|
||||||
<Badge
|
|
||||||
key={index}
|
|
||||||
variant='outline'
|
|
||||||
className='cursor-pointer gap-2 px-3 py-1.5 text-sm transition-colors hover:bg-accent'
|
|
||||||
onClick={() => openTagModal(index)}
|
|
||||||
>
|
|
||||||
<span className='font-medium'>{tag.displayName || 'Unnamed Tag'}</span>
|
|
||||||
{tag.value && (
|
|
||||||
<>
|
|
||||||
<span className='text-muted-foreground'>:</span>
|
|
||||||
<span className='text-muted-foreground'>{tag.value}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
variant='ghost'
|
|
||||||
size='sm'
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
handleRemoveTag(index)
|
|
||||||
}}
|
|
||||||
disabled={disabled}
|
|
||||||
className='ml-1 h-4 w-4 p-0 text-muted-foreground hover:text-red-600'
|
|
||||||
>
|
|
||||||
<X className='h-3 w-3' />
|
|
||||||
</Button>
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Add Tag Button */}
|
|
||||||
<Button
|
|
||||||
variant='outline'
|
|
||||||
size='sm'
|
|
||||||
onClick={openNewTagModal}
|
|
||||||
disabled={disabled}
|
|
||||||
className='gap-1 border-dashed text-muted-foreground hover:text-foreground'
|
|
||||||
>
|
|
||||||
<Plus className='h-4 w-4' />
|
|
||||||
Add Tag
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{tags.length === 0 && (
|
|
||||||
<div className='rounded-md border border-dashed p-4 text-center'>
|
|
||||||
<p className='text-muted-foreground text-sm'>
|
|
||||||
No tags added yet. Click "Add Tag" to get started.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className='text-muted-foreground text-xs'>
|
|
||||||
{kbTagDefinitions.length} of {MAX_TAG_SLOTS} tag slots used
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tag Edit Modal */}
|
|
||||||
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
|
||||||
<DialogContent className='sm:max-w-md'>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{editingTagIndex !== null ? 'Edit Tag' : 'Add New Tag'}</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className='space-y-4'>
|
|
||||||
{/* Tag Name */}
|
|
||||||
<div className='space-y-2'>
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<Label htmlFor='tag-name'>Tag Name</Label>
|
|
||||||
{editingTagIndex !== null && (
|
|
||||||
<Tooltip.Root>
|
|
||||||
<Tooltip.Trigger asChild>
|
|
||||||
<Info className='h-4 w-4 cursor-help text-muted-foreground' />
|
|
||||||
</Tooltip.Trigger>
|
|
||||||
<Tooltip.Content>
|
|
||||||
<p className='text-sm'>
|
|
||||||
Changing this tag name will update it for all documents in this knowledge
|
|
||||||
base
|
|
||||||
</p>
|
|
||||||
</Tooltip.Content>
|
|
||||||
</Tooltip.Root>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className='flex gap-2'>
|
|
||||||
<Input
|
|
||||||
id='tag-name'
|
|
||||||
value={editForm.displayName}
|
|
||||||
onChange={(e) => setEditForm({ ...editForm, displayName: e.target.value })}
|
|
||||||
placeholder='Enter tag name'
|
|
||||||
className='flex-1'
|
|
||||||
/>
|
|
||||||
{editingTagIndex === null && availableDefinitions.length > 0 && (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant='outline' size='sm'>
|
|
||||||
<ChevronDown className='h-4 w-4' />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align='end'>
|
|
||||||
{availableDefinitions.map((def) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={def.id}
|
|
||||||
onClick={() =>
|
|
||||||
setEditForm({
|
|
||||||
...editForm,
|
|
||||||
displayName: def.displayName,
|
|
||||||
fieldType: def.fieldType,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{def.displayName}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tag Type */}
|
|
||||||
<div className='space-y-2'>
|
|
||||||
<Label htmlFor='tag-type'>Type</Label>
|
|
||||||
<Select
|
|
||||||
value={editForm.fieldType}
|
|
||||||
onValueChange={(value) => setEditForm({ ...editForm, fieldType: value })}
|
|
||||||
disabled={editingTagIndex !== null} // Disable in edit mode
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value='text'>Text</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tag Value */}
|
|
||||||
<div className='space-y-2'>
|
|
||||||
<Label htmlFor='tag-value'>Value</Label>
|
|
||||||
<Input
|
|
||||||
id='tag-value'
|
|
||||||
value={editForm.value}
|
|
||||||
onChange={(e) => setEditForm({ ...editForm, value: e.target.value })}
|
|
||||||
placeholder='Enter tag value'
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Show warning when at max slots in create mode */}
|
|
||||||
{editingTagIndex === null && kbTagDefinitions.length >= MAX_TAG_SLOTS && (
|
|
||||||
<div className='rounded-md border border-amber-200 bg-amber-50 p-3'>
|
|
||||||
<div className='flex items-center gap-2 text-amber-800 text-sm'>
|
|
||||||
<span className='font-medium'>Maximum tag definitions reached</span>
|
|
||||||
</div>
|
|
||||||
<p className='mt-1 text-amber-700 text-xs'>
|
|
||||||
You can still use existing tag definitions from the dropdown, but cannot create new
|
|
||||||
ones.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className='flex justify-end gap-2 pt-4'>
|
|
||||||
<Button variant='outline' onClick={() => setModalOpen(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={saveTagFromModal}
|
|
||||||
disabled={(() => {
|
|
||||||
if (!editForm.displayName.trim()) return true
|
|
||||||
|
|
||||||
// In edit mode, always allow
|
|
||||||
if (editingTagIndex !== null) return false
|
|
||||||
|
|
||||||
// In create mode, check if we're creating a new definition at max slots
|
|
||||||
const existingDefinition = kbTagDefinitions.find(
|
|
||||||
(def) => def.displayName.toLowerCase() === editForm.displayName.toLowerCase()
|
|
||||||
)
|
|
||||||
|
|
||||||
// If using existing definition, allow
|
|
||||||
if (existingDefinition) return false
|
|
||||||
|
|
||||||
// If creating new definition and at max slots, disable
|
|
||||||
return kbTagDefinitions.length >= MAX_TAG_SLOTS
|
|
||||||
})()}
|
|
||||||
>
|
|
||||||
{(() => {
|
|
||||||
if (editingTagIndex !== null) {
|
|
||||||
return 'Save Changes'
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingDefinition = kbTagDefinitions.find(
|
|
||||||
(def) => def.displayName.toLowerCase() === editForm.displayName.toLowerCase()
|
|
||||||
)
|
|
||||||
|
|
||||||
if (existingDefinition) {
|
|
||||||
return 'Use Existing Tag'
|
|
||||||
}
|
|
||||||
if (kbTagDefinitions.length >= MAX_TAG_SLOTS) {
|
|
||||||
return 'Max Tags Reached'
|
|
||||||
}
|
|
||||||
return 'Create New Tag'
|
|
||||||
})()}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { LibraryBig } from 'lucide-react'
|
|
||||||
|
|
||||||
interface EmptyStateCardProps {
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
buttonText: string
|
|
||||||
onClick: () => void
|
|
||||||
icon?: React.ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EmptyStateCard({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
buttonText,
|
|
||||||
onClick,
|
|
||||||
icon,
|
|
||||||
}: EmptyStateCardProps) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
onClick={onClick}
|
|
||||||
className='group flex cursor-pointer flex-col gap-3 rounded-md border border-muted-foreground/25 border-dashed bg-background p-4 transition-colors hover:border-muted-foreground/40 hover:bg-accent/50'
|
|
||||||
>
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
{icon || <LibraryBig className='h-4 w-4 flex-shrink-0 text-muted-foreground' />}
|
|
||||||
<h3 className='truncate font-medium text-sm leading-tight'>{title}</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='flex flex-col gap-2'>
|
|
||||||
<div className='flex items-center gap-2 text-muted-foreground text-xs'>
|
|
||||||
<span>Get started</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className='line-clamp-2 overflow-hidden text-muted-foreground text-xs'>{description}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,4 @@
|
|||||||
export { BaseOverview } from './base-overview/base-overview'
|
export { BaseCard, BaseCardSkeleton, BaseCardSkeletonGrid } from './base-card/base-card'
|
||||||
export { CreateModal } from './create-modal/create-modal'
|
export { CreateBaseModal } from './create-base-modal/create-base-modal'
|
||||||
export { EmptyStateCard } from './empty-state-card/empty-state-card'
|
|
||||||
export { getDocumentIcon } from './icons/document-icons'
|
export { getDocumentIcon } from './icons/document-icons'
|
||||||
export { KnowledgeHeader } from './knowledge-header/knowledge-header'
|
export { KnowledgeHeader } from './knowledge-header/knowledge-header'
|
||||||
export { PrimaryButton } from './primary-button/primary-button'
|
|
||||||
export { SearchInput } from './search-input/search-input'
|
|
||||||
export { KnowledgeBaseCardSkeletonGrid } from './skeletons/knowledge-base-card-skeleton'
|
|
||||||
export { ChunkTableSkeleton, DocumentTableSkeleton } from './skeletons/table-skeleton'
|
|
||||||
export { type TagData, TagInput } from './tag-input/tag-input'
|
|
||||||
export { WorkspaceSelector } from './workspace-selector/workspace-selector'
|
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { LibraryBig, MoreHorizontal } from 'lucide-react'
|
import { AlertTriangle, ChevronDown, LibraryBig, MoreHorizontal } from 'lucide-react'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { Button, Popover, PopoverContent, PopoverItem, PopoverTrigger } from '@/components/emcn'
|
import {
|
||||||
|
Button,
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverItem,
|
||||||
|
PopoverTrigger,
|
||||||
|
Tooltip,
|
||||||
|
} from '@/components/emcn'
|
||||||
import { Trash } from '@/components/emcn/icons/trash'
|
import { Trash } from '@/components/emcn/icons/trash'
|
||||||
import { WorkspaceSelector } from '@/app/workspace/[workspaceId]/knowledge/components'
|
import { createLogger } from '@/lib/logs/console/logger'
|
||||||
import { filterButtonClass } from '@/app/workspace/[workspaceId]/knowledge/components/shared'
|
import { filterButtonClass } from '@/app/workspace/[workspaceId]/knowledge/components/constants'
|
||||||
|
import { useKnowledgeStore } from '@/stores/knowledge/store'
|
||||||
|
|
||||||
|
const logger = createLogger('KnowledgeHeader')
|
||||||
|
|
||||||
interface BreadcrumbItem {
|
interface BreadcrumbItem {
|
||||||
label: string
|
label: string
|
||||||
@@ -27,7 +37,7 @@ const HEADER_STYLES = {
|
|||||||
interface KnowledgeHeaderOptions {
|
interface KnowledgeHeaderOptions {
|
||||||
knowledgeBaseId?: string
|
knowledgeBaseId?: string
|
||||||
currentWorkspaceId?: string | null
|
currentWorkspaceId?: string | null
|
||||||
onWorkspaceChange?: (workspaceId: string | null) => void
|
onWorkspaceChange?: (workspaceId: string | null) => void | Promise<void>
|
||||||
onDeleteKnowledgeBase?: () => void
|
onDeleteKnowledgeBase?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,8 +46,101 @@ interface KnowledgeHeaderProps {
|
|||||||
options?: KnowledgeHeaderOptions
|
options?: KnowledgeHeaderOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface Workspace {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
permissions: 'admin' | 'write' | 'read'
|
||||||
|
}
|
||||||
|
|
||||||
export function KnowledgeHeader({ breadcrumbs, options }: KnowledgeHeaderProps) {
|
export function KnowledgeHeader({ breadcrumbs, options }: KnowledgeHeaderProps) {
|
||||||
|
const { updateKnowledgeBase } = useKnowledgeStore()
|
||||||
const [isActionsPopoverOpen, setIsActionsPopoverOpen] = useState(false)
|
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)
|
||||||
|
|
||||||
|
// Fetch available workspaces
|
||||||
|
useEffect(() => {
|
||||||
|
if (!options?.knowledgeBaseId) return
|
||||||
|
|
||||||
|
const fetchWorkspaces = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoadingWorkspaces(true)
|
||||||
|
|
||||||
|
const response = await fetch('/api/workspaces')
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch workspaces')
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => ({
|
||||||
|
id: ws.id,
|
||||||
|
name: ws.name,
|
||||||
|
permissions: ws.permissions,
|
||||||
|
}))
|
||||||
|
|
||||||
|
setWorkspaces(availableWorkspaces)
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Error fetching workspaces:', err)
|
||||||
|
} finally {
|
||||||
|
setIsLoadingWorkspaces(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchWorkspaces()
|
||||||
|
}, [options?.knowledgeBaseId])
|
||||||
|
|
||||||
|
const handleWorkspaceChange = async (workspaceId: string | null) => {
|
||||||
|
if (isUpdatingWorkspace || !options?.knowledgeBaseId) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsUpdatingWorkspace(true)
|
||||||
|
setIsWorkspacePopoverOpen(false)
|
||||||
|
|
||||||
|
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}`
|
||||||
|
)
|
||||||
|
|
||||||
|
// Notify parent component of the change to refresh data
|
||||||
|
await options.onWorkspaceChange?.(workspaceId)
|
||||||
|
|
||||||
|
// Update the store after refresh to ensure consistency
|
||||||
|
updateKnowledgeBase(options.knowledgeBaseId, { workspaceId: workspaceId || undefined })
|
||||||
|
} else {
|
||||||
|
throw new Error(result.error || 'Failed to update workspace')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Error updating workspace:', err)
|
||||||
|
} finally {
|
||||||
|
setIsUpdatingWorkspace(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentWorkspace = workspaces.find((ws) => ws.id === options?.currentWorkspaceId)
|
||||||
|
const hasWorkspace = !!options?.currentWorkspaceId
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={HEADER_STYLES.container}>
|
<div className={HEADER_STYLES.container}>
|
||||||
@@ -69,11 +172,67 @@ export function KnowledgeHeader({ breadcrumbs, options }: KnowledgeHeaderProps)
|
|||||||
<div className={HEADER_STYLES.actionsContainer}>
|
<div className={HEADER_STYLES.actionsContainer}>
|
||||||
{/* Workspace Selector */}
|
{/* Workspace Selector */}
|
||||||
{options.knowledgeBaseId && (
|
{options.knowledgeBaseId && (
|
||||||
<WorkspaceSelector
|
<div className='flex items-center gap-2'>
|
||||||
knowledgeBaseId={options.knowledgeBaseId}
|
{/* Warning icon for unassigned knowledge bases */}
|
||||||
currentWorkspaceId={options.currentWorkspaceId || null}
|
{!hasWorkspace && (
|
||||||
onWorkspaceChange={options.onWorkspaceChange}
|
<Tooltip.Root>
|
||||||
/>
|
<Tooltip.Trigger asChild>
|
||||||
|
<AlertTriangle className='h-4 w-4 text-amber-500' />
|
||||||
|
</Tooltip.Trigger>
|
||||||
|
<Tooltip.Content side='top'>Not assigned to workspace</Tooltip.Content>
|
||||||
|
</Tooltip.Root>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Workspace selector dropdown */}
|
||||||
|
<Popover open={isWorkspacePopoverOpen} onOpenChange={setIsWorkspacePopoverOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
disabled={isLoadingWorkspaces || isUpdatingWorkspace}
|
||||||
|
className={filterButtonClass}
|
||||||
|
>
|
||||||
|
<span className='truncate'>
|
||||||
|
{isLoadingWorkspaces
|
||||||
|
? 'Loading...'
|
||||||
|
: isUpdatingWorkspace
|
||||||
|
? 'Updating...'
|
||||||
|
: currentWorkspace?.name || 'No workspace'}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align='end' side='bottom' sideOffset={4}>
|
||||||
|
{/* No workspace option */}
|
||||||
|
<PopoverItem
|
||||||
|
active={!options.currentWorkspaceId}
|
||||||
|
showCheck
|
||||||
|
onClick={() => handleWorkspaceChange(null)}
|
||||||
|
>
|
||||||
|
<span className='text-muted-foreground'>No workspace</span>
|
||||||
|
</PopoverItem>
|
||||||
|
|
||||||
|
{/* Available workspaces */}
|
||||||
|
{workspaces.map((workspace) => (
|
||||||
|
<PopoverItem
|
||||||
|
key={workspace.id}
|
||||||
|
active={options.currentWorkspaceId === workspace.id}
|
||||||
|
showCheck
|
||||||
|
onClick={() => handleWorkspaceChange(workspace.id)}
|
||||||
|
>
|
||||||
|
{workspace.name}
|
||||||
|
</PopoverItem>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{workspaces.length === 0 && !isLoadingWorkspaces && (
|
||||||
|
<PopoverItem disabled>
|
||||||
|
<span className='text-muted-foreground text-xs'>
|
||||||
|
No workspaces with write access
|
||||||
|
</span>
|
||||||
|
</PopoverItem>
|
||||||
|
)}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions Menu */}
|
{/* Actions Menu */}
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { Button } from '@/components/emcn'
|
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
|
||||||
|
|
||||||
interface PrimaryButtonProps {
|
|
||||||
children: React.ReactNode
|
|
||||||
onClick?: () => void
|
|
||||||
disabled?: boolean
|
|
||||||
className?: string
|
|
||||||
type?: 'button' | 'submit' | 'reset'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PrimaryButton({
|
|
||||||
children,
|
|
||||||
onClick,
|
|
||||||
disabled = false,
|
|
||||||
className,
|
|
||||||
type = 'button',
|
|
||||||
}: PrimaryButtonProps) {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
type={type}
|
|
||||||
onClick={onClick}
|
|
||||||
disabled={disabled}
|
|
||||||
variant='primary'
|
|
||||||
className={cn(
|
|
||||||
'flex h-8 items-center gap-1 px-[8px] py-[6px] font-[480] shadow-[0_0_0_0_var(--brand-primary-hex)] transition-all duration-200 hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)]',
|
|
||||||
disabled && 'disabled:cursor-not-allowed disabled:opacity-50',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { Search, X } from 'lucide-react'
|
|
||||||
|
|
||||||
interface SearchInputProps {
|
|
||||||
value: string
|
|
||||||
onChange: (value: string) => void
|
|
||||||
placeholder: string
|
|
||||||
disabled?: boolean
|
|
||||||
className?: string
|
|
||||||
isLoading?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SearchInput({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
placeholder,
|
|
||||||
disabled = false,
|
|
||||||
className = 'max-w-md flex-1',
|
|
||||||
isLoading = false,
|
|
||||||
}: SearchInputProps) {
|
|
||||||
return (
|
|
||||||
<div className={`relative ${className}`}>
|
|
||||||
<div className='relative flex items-center'>
|
|
||||||
<Search className='-translate-y-1/2 pointer-events-none absolute top-1/2 left-3 h-[18px] w-[18px] transform text-muted-foreground' />
|
|
||||||
<input
|
|
||||||
type='text'
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
placeholder={placeholder}
|
|
||||||
disabled={disabled}
|
|
||||||
className='h-10 w-full rounded-md border bg-background px-9 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:font-medium file:text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50'
|
|
||||||
/>
|
|
||||||
{isLoading ? (
|
|
||||||
<div className='-translate-y-1/2 absolute top-1/2 right-3'>
|
|
||||||
<div className='h-[18px] w-[18px] animate-spin rounded-full border-2 border-gray-300 border-t-[var(--brand-primary-hex)]' />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
value &&
|
|
||||||
!disabled && (
|
|
||||||
<button
|
|
||||||
onClick={() => onChange('')}
|
|
||||||
className='-translate-y-1/2 absolute top-1/2 right-3 transform text-muted-foreground hover:text-foreground'
|
|
||||||
>
|
|
||||||
<X className='h-[18px] w-[18px]' />
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
export function KnowledgeBaseCardSkeleton() {
|
|
||||||
return (
|
|
||||||
<div className='rounded-lg border bg-background p-4'>
|
|
||||||
<div className='flex items-start justify-between'>
|
|
||||||
<div className='flex-1 space-y-3'>
|
|
||||||
{/* Title skeleton */}
|
|
||||||
<div className='h-4 w-3/4 animate-pulse rounded bg-muted' />
|
|
||||||
|
|
||||||
{/* Description skeleton */}
|
|
||||||
<div className='space-y-2'>
|
|
||||||
<div className='h-3 w-full animate-pulse rounded bg-muted' />
|
|
||||||
<div className='h-3 w-2/3 animate-pulse rounded bg-muted' />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats skeleton */}
|
|
||||||
<div className='flex items-center gap-4 pt-2'>
|
|
||||||
<div className='flex items-center gap-1'>
|
|
||||||
<div className='h-3 w-3 animate-pulse rounded bg-muted' />
|
|
||||||
<div className='h-3 w-8 animate-pulse rounded bg-muted' />
|
|
||||||
</div>
|
|
||||||
<div className='flex items-center gap-1'>
|
|
||||||
<div className='h-3 w-3 animate-pulse rounded bg-muted' />
|
|
||||||
<div className='h-3 w-12 animate-pulse rounded bg-muted' />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function KnowledgeBaseCardSkeletonGrid({ count = 8 }: { count?: number }) {
|
|
||||||
return (
|
|
||||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
|
|
||||||
{Array.from({ length: count }).map((_, i) => (
|
|
||||||
<KnowledgeBaseCardSkeleton key={i} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
export function DocumentTableRowSkeleton({ isSidebarCollapsed }: { isSidebarCollapsed: boolean }) {
|
|
||||||
return (
|
|
||||||
<tr className='border-b'>
|
|
||||||
{/* Select column */}
|
|
||||||
<td className='px-4 py-3'>
|
|
||||||
<div className='h-3.5 w-3.5 animate-pulse rounded bg-muted' />
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Name column */}
|
|
||||||
<td className='px-4 py-3'>
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<div className='h-6 w-5 animate-pulse rounded bg-muted' />
|
|
||||||
<div className='h-4 w-32 animate-pulse rounded bg-muted' />
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Size column */}
|
|
||||||
<td className='px-4 py-3'>
|
|
||||||
<div className='h-3 w-12 animate-pulse rounded bg-muted' />
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Tokens column */}
|
|
||||||
<td className='px-4 py-3'>
|
|
||||||
<div className='h-3 w-8 animate-pulse rounded bg-muted' />
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Chunks column - hidden on small screens */}
|
|
||||||
<td className='hidden px-4 py-3 lg:table-cell'>
|
|
||||||
<div className='h-3 w-6 animate-pulse rounded bg-muted' />
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Upload Time column */}
|
|
||||||
<td className='px-4 py-3'>
|
|
||||||
<div className='space-y-1'>
|
|
||||||
<div className='h-3 w-16 animate-pulse rounded bg-muted' />
|
|
||||||
<div className='h-3 w-12 animate-pulse rounded bg-muted lg:hidden' />
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Status column */}
|
|
||||||
<td className='px-4 py-3'>
|
|
||||||
<div className='h-6 w-16 animate-pulse rounded-md bg-muted' />
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Actions column */}
|
|
||||||
<td className='px-4 py-3'>
|
|
||||||
<div className='flex items-center gap-1'>
|
|
||||||
<div className='h-8 w-8 animate-pulse rounded bg-muted' />
|
|
||||||
<div className='h-8 w-8 animate-pulse rounded bg-muted' />
|
|
||||||
<div className='h-8 w-8 animate-pulse rounded bg-muted' />
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChunkTableRowSkeleton({ isSidebarCollapsed }: { isSidebarCollapsed: boolean }) {
|
|
||||||
return (
|
|
||||||
<tr className='border-b'>
|
|
||||||
{/* Select column */}
|
|
||||||
<td className='px-4 py-3'>
|
|
||||||
<div className='h-3.5 w-3.5 animate-pulse rounded bg-muted' />
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Index column */}
|
|
||||||
<td className='px-4 py-3'>
|
|
||||||
<div className='h-4 w-6 animate-pulse rounded bg-muted' />
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Content column */}
|
|
||||||
<td className='px-4 py-3'>
|
|
||||||
<div className='space-y-2'>
|
|
||||||
<div className='h-4 w-full animate-pulse rounded bg-muted' />
|
|
||||||
<div className='h-4 w-3/4 animate-pulse rounded bg-muted' />
|
|
||||||
<div className='h-4 w-1/2 animate-pulse rounded bg-muted' />
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Tokens column */}
|
|
||||||
<td className='px-4 py-3'>
|
|
||||||
<div className='h-3 w-8 animate-pulse rounded bg-muted' />
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Status column */}
|
|
||||||
<td className='px-4 py-3'>
|
|
||||||
<div className='h-6 w-16 animate-pulse rounded-md bg-muted' />
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Actions column */}
|
|
||||||
<td className='px-4 py-3'>
|
|
||||||
<div className='flex items-center gap-1'>
|
|
||||||
<div className='h-8 w-8 animate-pulse rounded bg-muted' />
|
|
||||||
<div className='h-8 w-8 animate-pulse rounded bg-muted' />
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DocumentTableSkeleton({
|
|
||||||
isSidebarCollapsed,
|
|
||||||
rowCount = 5,
|
|
||||||
}: {
|
|
||||||
isSidebarCollapsed: boolean
|
|
||||||
rowCount?: number
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className='flex flex-1 flex-col overflow-hidden'>
|
|
||||||
{/* Table header - fixed */}
|
|
||||||
<div className='sticky top-0 z-10 overflow-x-auto border-b bg-background'>
|
|
||||||
<table className='w-full min-w-[700px] table-fixed'>
|
|
||||||
<colgroup>
|
|
||||||
<col className='w-[4%]' />
|
|
||||||
<col className={`${isSidebarCollapsed ? 'w-[22%]' : 'w-[24%]'}`} />
|
|
||||||
<col className='w-[8%]' />
|
|
||||||
<col className='w-[8%]' />
|
|
||||||
<col className='hidden w-[8%] lg:table-column' />
|
|
||||||
<col className={`${isSidebarCollapsed ? 'w-[18%]' : 'w-[16%]'}`} />
|
|
||||||
<col className='w-[12%]' />
|
|
||||||
<col className='w-[14%]' />
|
|
||||||
</colgroup>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th className='px-4 pt-2 pb-3 text-left font-medium'>
|
|
||||||
<div className='h-3.5 w-3.5 animate-pulse rounded bg-muted' />
|
|
||||||
</th>
|
|
||||||
<th className='px-4 pt-2 pb-3 text-left font-medium'>
|
|
||||||
<span className='text-muted-foreground text-xs leading-none'>Name</span>
|
|
||||||
</th>
|
|
||||||
<th className='px-4 pt-2 pb-3 text-left font-medium'>
|
|
||||||
<span className='text-muted-foreground text-xs leading-none'>Size</span>
|
|
||||||
</th>
|
|
||||||
<th className='px-4 pt-2 pb-3 text-left font-medium'>
|
|
||||||
<span className='text-muted-foreground text-xs leading-none'>Tokens</span>
|
|
||||||
</th>
|
|
||||||
<th className='hidden px-4 pt-2 pb-3 text-left font-medium lg:table-cell'>
|
|
||||||
<span className='text-muted-foreground text-xs leading-none'>Chunks</span>
|
|
||||||
</th>
|
|
||||||
<th className='px-4 pt-2 pb-3 text-left font-medium'>
|
|
||||||
<span className='text-muted-foreground text-xs leading-none'>Uploaded</span>
|
|
||||||
</th>
|
|
||||||
<th className='px-4 pt-2 pb-3 text-left font-medium'>
|
|
||||||
<span className='text-muted-foreground text-xs leading-none'>Status</span>
|
|
||||||
</th>
|
|
||||||
<th className='px-4 pt-2 pb-3 text-left font-medium'>
|
|
||||||
<span className='text-muted-foreground text-xs leading-none'>Actions</span>
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Table body - scrollable */}
|
|
||||||
<div className='flex-1 overflow-auto'>
|
|
||||||
<table className='w-full min-w-[700px] table-fixed'>
|
|
||||||
<colgroup>
|
|
||||||
<col className='w-[4%]' />
|
|
||||||
<col className={`${isSidebarCollapsed ? 'w-[22%]' : 'w-[24%]'}`} />
|
|
||||||
<col className='w-[8%]' />
|
|
||||||
<col className='w-[8%]' />
|
|
||||||
<col className='hidden w-[8%] lg:table-column' />
|
|
||||||
<col className={`${isSidebarCollapsed ? 'w-[18%]' : 'w-[16%]'}`} />
|
|
||||||
<col className='w-[12%]' />
|
|
||||||
<col className='w-[14%]' />
|
|
||||||
</colgroup>
|
|
||||||
<tbody>
|
|
||||||
{Array.from({ length: rowCount }).map((_, i) => (
|
|
||||||
<DocumentTableRowSkeleton key={i} isSidebarCollapsed={isSidebarCollapsed} />
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChunkTableSkeleton({
|
|
||||||
isSidebarCollapsed,
|
|
||||||
rowCount = 5,
|
|
||||||
}: {
|
|
||||||
isSidebarCollapsed: boolean
|
|
||||||
rowCount?: number
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className='flex flex-1 flex-col overflow-hidden'>
|
|
||||||
{/* Table header - fixed */}
|
|
||||||
<div className='sticky top-0 z-10 border-b bg-background'>
|
|
||||||
<table className='w-full table-fixed'>
|
|
||||||
<colgroup>
|
|
||||||
<col className='w-[5%]' />
|
|
||||||
<col className='w-[8%]' />
|
|
||||||
<col className={`${isSidebarCollapsed ? 'w-[57%]' : 'w-[55%]'}`} />
|
|
||||||
<col className='w-[10%]' />
|
|
||||||
<col className='w-[10%]' />
|
|
||||||
<col className='w-[12%]' />
|
|
||||||
</colgroup>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th className='px-4 pt-2 pb-3 text-left font-medium'>
|
|
||||||
<div className='h-3.5 w-3.5 animate-pulse rounded bg-muted' />
|
|
||||||
</th>
|
|
||||||
<th className='px-4 pt-2 pb-3 text-left font-medium'>
|
|
||||||
<span className='text-muted-foreground text-xs leading-none'>Index</span>
|
|
||||||
</th>
|
|
||||||
<th className='px-4 pt-2 pb-3 text-left font-medium'>
|
|
||||||
<span className='text-muted-foreground text-xs leading-none'>Content</span>
|
|
||||||
</th>
|
|
||||||
<th className='px-4 pt-2 pb-3 text-left font-medium'>
|
|
||||||
<span className='text-muted-foreground text-xs leading-none'>Tokens</span>
|
|
||||||
</th>
|
|
||||||
<th className='px-4 pt-2 pb-3 text-left font-medium'>
|
|
||||||
<span className='text-muted-foreground text-xs leading-none'>Status</span>
|
|
||||||
</th>
|
|
||||||
<th className='px-4 pt-2 pb-3 text-left font-medium'>
|
|
||||||
<span className='text-muted-foreground text-xs leading-none'>Actions</span>
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Table body - scrollable */}
|
|
||||||
<div className='flex-1 overflow-auto'>
|
|
||||||
<table className='w-full table-fixed'>
|
|
||||||
<colgroup>
|
|
||||||
<col className='w-[5%]' />
|
|
||||||
<col className='w-[8%]' />
|
|
||||||
<col className={`${isSidebarCollapsed ? 'w-[57%]' : 'w-[55%]'}`} />
|
|
||||||
<col className='w-[10%]' />
|
|
||||||
<col className='w-[10%]' />
|
|
||||||
<col className='w-[12%]' />
|
|
||||||
</colgroup>
|
|
||||||
<tbody>
|
|
||||||
{Array.from({ length: rowCount }).map((_, i) => (
|
|
||||||
<ChunkTableRowSkeleton key={i} isSidebarCollapsed={isSidebarCollapsed} />
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { ChevronDown, ChevronRight, Plus, Settings, X } from 'lucide-react'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import { TAG_SLOTS, type TagSlot } from '@/lib/knowledge/constants'
|
|
||||||
import { useKnowledgeBaseTagDefinitions } from '@/hooks/use-knowledge-base-tag-definitions'
|
|
||||||
|
|
||||||
export type TagData = {
|
|
||||||
[K in TagSlot]?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TagInputProps {
|
|
||||||
tags: TagData
|
|
||||||
onTagsChange: (tags: TagData) => void
|
|
||||||
disabled?: boolean
|
|
||||||
className?: string
|
|
||||||
knowledgeBaseId?: string | null
|
|
||||||
documentId?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const TAG_LABELS = TAG_SLOTS.map((slot, index) => ({
|
|
||||||
key: slot as keyof TagData,
|
|
||||||
label: `Tag ${index + 1}`,
|
|
||||||
placeholder: 'Enter tag value',
|
|
||||||
}))
|
|
||||||
|
|
||||||
export function TagInput({
|
|
||||||
tags,
|
|
||||||
onTagsChange,
|
|
||||||
disabled = false,
|
|
||||||
className = '',
|
|
||||||
knowledgeBaseId = null,
|
|
||||||
documentId = null,
|
|
||||||
}: TagInputProps) {
|
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
|
||||||
const [showAllTags, setShowAllTags] = useState(false)
|
|
||||||
|
|
||||||
// Use custom tag definitions if available
|
|
||||||
const { getTagLabel } = useKnowledgeBaseTagDefinitions(knowledgeBaseId)
|
|
||||||
|
|
||||||
const handleTagChange = (tagKey: keyof TagData, value: string) => {
|
|
||||||
onTagsChange({
|
|
||||||
...tags,
|
|
||||||
[tagKey]: value.trim() || undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearTag = (tagKey: keyof TagData) => {
|
|
||||||
onTagsChange({
|
|
||||||
...tags,
|
|
||||||
[tagKey]: undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasAnyTags = Object.values(tags).some((tag) => tag?.trim())
|
|
||||||
|
|
||||||
// Create tag labels using custom definitions or fallback to defaults
|
|
||||||
const tagLabels = TAG_LABELS.map(({ key, placeholder }) => ({
|
|
||||||
key,
|
|
||||||
label: getTagLabel(key),
|
|
||||||
placeholder,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const visibleTags = showAllTags ? tagLabels : tagLabels.slice(0, 2)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={className}>
|
|
||||||
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
|
||||||
<CollapsibleTrigger asChild>
|
|
||||||
<Button
|
|
||||||
type='button'
|
|
||||||
variant='ghost'
|
|
||||||
className='flex h-auto w-full justify-between p-0 hover:bg-transparent'
|
|
||||||
>
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<Settings className='h-4 w-4 text-muted-foreground' />
|
|
||||||
<Label className='cursor-pointer font-medium text-sm'>Advanced Settings</Label>
|
|
||||||
{hasAnyTags && (
|
|
||||||
<span className='rounded-full bg-primary/10 px-2 py-0.5 text-muted-foreground text-xs'>
|
|
||||||
{Object.values(tags).filter((tag) => tag?.trim()).length} tag
|
|
||||||
{Object.values(tags).filter((tag) => tag?.trim()).length !== 1 ? 's' : ''}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{isOpen ? (
|
|
||||||
<ChevronDown className='h-4 w-4 text-muted-foreground' />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className='h-4 w-4 text-muted-foreground' />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
|
|
||||||
<CollapsibleContent className='space-y-4 pt-4'>
|
|
||||||
<div className='space-y-3'>
|
|
||||||
<div className='flex items-center justify-between'>
|
|
||||||
<Label className='font-medium text-sm'>Document Tags</Label>
|
|
||||||
{!showAllTags && (
|
|
||||||
<Button
|
|
||||||
type='button'
|
|
||||||
variant='ghost'
|
|
||||||
size='sm'
|
|
||||||
onClick={() => setShowAllTags(true)}
|
|
||||||
className='h-auto p-1 text-muted-foreground text-xs hover:text-foreground'
|
|
||||||
>
|
|
||||||
<Plus className='mr-1 h-3 w-3' />
|
|
||||||
More tags
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='grid grid-cols-1 gap-3 sm:grid-cols-2'>
|
|
||||||
{visibleTags.map(({ key, label, placeholder }) => (
|
|
||||||
<div key={key} className='space-y-1'>
|
|
||||||
<Label htmlFor={key} className='text-muted-foreground text-xs'>
|
|
||||||
{label}
|
|
||||||
</Label>
|
|
||||||
<div className='relative'>
|
|
||||||
<Input
|
|
||||||
id={key}
|
|
||||||
type='text'
|
|
||||||
value={tags[key] || ''}
|
|
||||||
onChange={(e) => handleTagChange(key, e.target.value)}
|
|
||||||
placeholder={placeholder}
|
|
||||||
disabled={disabled}
|
|
||||||
className='pr-8 text-sm'
|
|
||||||
/>
|
|
||||||
{tags[key] && (
|
|
||||||
<Button
|
|
||||||
type='button'
|
|
||||||
variant='ghost'
|
|
||||||
size='sm'
|
|
||||||
onClick={() => clearTag(key)}
|
|
||||||
disabled={disabled}
|
|
||||||
className='-translate-y-1/2 absolute top-1/2 right-1 h-6 w-6 p-0 hover:bg-muted'
|
|
||||||
>
|
|
||||||
<X className='h-3 w-3' />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showAllTags && (
|
|
||||||
<div className='flex justify-center'>
|
|
||||||
<Button
|
|
||||||
type='button'
|
|
||||||
variant='ghost'
|
|
||||||
size='sm'
|
|
||||||
onClick={() => setShowAllTags(false)}
|
|
||||||
className='h-auto p-1 text-muted-foreground text-xs hover:text-foreground'
|
|
||||||
>
|
|
||||||
Show fewer tags
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasAnyTags && (
|
|
||||||
<div className='rounded-md bg-muted/50 p-3'>
|
|
||||||
<p className='mb-2 text-muted-foreground text-xs'>Active tags:</p>
|
|
||||||
<div className='flex flex-wrap gap-1'>
|
|
||||||
{Object.entries(tags).map(([key, value]) => {
|
|
||||||
if (!value?.trim()) return null
|
|
||||||
const tagLabel = getTagLabel(key)
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
key={key}
|
|
||||||
className='inline-flex items-center gap-1 rounded-md bg-primary/10 px-2 py-1 text-muted-foreground text-xs'
|
|
||||||
>
|
|
||||||
<span className='font-medium'>{tagLabel}:</span>
|
|
||||||
<span>{value}</span>
|
|
||||||
<Button
|
|
||||||
type='button'
|
|
||||||
variant='ghost'
|
|
||||||
size='sm'
|
|
||||||
onClick={() => clearTag(key as keyof TagData)}
|
|
||||||
disabled={disabled}
|
|
||||||
className='h-3 w-3 p-0 hover:bg-primary/20'
|
|
||||||
>
|
|
||||||
<X className='h-2 w-2' />
|
|
||||||
</Button>
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</Collapsible>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { AlertTriangle, ChevronDown } from 'lucide-react'
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverItem,
|
|
||||||
PopoverTrigger,
|
|
||||||
Tooltip,
|
|
||||||
} from '@/components/emcn'
|
|
||||||
import { createLogger } from '@/lib/logs/console/logger'
|
|
||||||
import { filterButtonClass } from '@/app/workspace/[workspaceId]/knowledge/components/shared'
|
|
||||||
import { useKnowledgeStore } from '@/stores/knowledge/store'
|
|
||||||
|
|
||||||
const logger = createLogger('WorkspaceSelector')
|
|
||||||
|
|
||||||
interface Workspace {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
permissions: 'admin' | 'write' | 'read'
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WorkspaceSelectorProps {
|
|
||||||
knowledgeBaseId: string
|
|
||||||
currentWorkspaceId: string | null
|
|
||||||
onWorkspaceChange?: (workspaceId: string | null) => void
|
|
||||||
disabled?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WorkspaceSelector({
|
|
||||||
knowledgeBaseId,
|
|
||||||
currentWorkspaceId,
|
|
||||||
onWorkspaceChange,
|
|
||||||
disabled = false,
|
|
||||||
}: WorkspaceSelectorProps) {
|
|
||||||
const { updateKnowledgeBase } = useKnowledgeStore()
|
|
||||||
const [workspaces, setWorkspaces] = useState<Workspace[]>([])
|
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
|
||||||
const [isUpdating, setIsUpdating] = useState(false)
|
|
||||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false)
|
|
||||||
|
|
||||||
// Fetch available workspaces
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchWorkspaces = async () => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true)
|
|
||||||
|
|
||||||
const response = await fetch('/api/workspaces')
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to fetch workspaces')
|
|
||||||
}
|
|
||||||
|
|
||||||
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) => ({
|
|
||||||
id: ws.id,
|
|
||||||
name: ws.name,
|
|
||||||
permissions: ws.permissions,
|
|
||||||
}))
|
|
||||||
|
|
||||||
setWorkspaces(availableWorkspaces)
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('Error fetching workspaces:', err)
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchWorkspaces()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleWorkspaceChange = async (workspaceId: string | null) => {
|
|
||||||
if (isUpdating || disabled) return
|
|
||||||
|
|
||||||
try {
|
|
||||||
setIsUpdating(true)
|
|
||||||
setIsPopoverOpen(false)
|
|
||||||
|
|
||||||
const response = await fetch(`/api/knowledge/${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: ${knowledgeBaseId} -> ${workspaceId}`)
|
|
||||||
|
|
||||||
// Notify parent component of the change to refresh data
|
|
||||||
await onWorkspaceChange?.(workspaceId)
|
|
||||||
|
|
||||||
// Update the store after refresh to ensure consistency
|
|
||||||
updateKnowledgeBase(knowledgeBaseId, { workspaceId: workspaceId || undefined })
|
|
||||||
} else {
|
|
||||||
throw new Error(result.error || 'Failed to update workspace')
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('Error updating workspace:', err)
|
|
||||||
} finally {
|
|
||||||
setIsUpdating(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentWorkspace = workspaces.find((ws) => ws.id === currentWorkspaceId)
|
|
||||||
const hasWorkspace = !!currentWorkspaceId
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
{/* Warning icon for unassigned knowledge bases */}
|
|
||||||
{!hasWorkspace && (
|
|
||||||
<Tooltip.Root>
|
|
||||||
<Tooltip.Trigger asChild>
|
|
||||||
<AlertTriangle className='h-4 w-4 text-amber-500' />
|
|
||||||
</Tooltip.Trigger>
|
|
||||||
<Tooltip.Content side='top'>Not assigned to workspace</Tooltip.Content>
|
|
||||||
</Tooltip.Root>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Workspace selector dropdown */}
|
|
||||||
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant='outline'
|
|
||||||
disabled={disabled || isLoading || isUpdating}
|
|
||||||
className={filterButtonClass}
|
|
||||||
>
|
|
||||||
<span className='truncate'>
|
|
||||||
{isLoading
|
|
||||||
? 'Loading...'
|
|
||||||
: isUpdating
|
|
||||||
? 'Updating...'
|
|
||||||
: currentWorkspace?.name || 'No workspace'}
|
|
||||||
</span>
|
|
||||||
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent align='end' side='bottom' sideOffset={4}>
|
|
||||||
{/* No workspace option */}
|
|
||||||
<PopoverItem
|
|
||||||
active={!currentWorkspaceId}
|
|
||||||
showCheck
|
|
||||||
onClick={() => handleWorkspaceChange(null)}
|
|
||||||
>
|
|
||||||
<span className='text-muted-foreground'>No workspace</span>
|
|
||||||
</PopoverItem>
|
|
||||||
|
|
||||||
{/* Available workspaces */}
|
|
||||||
{workspaces.map((workspace) => (
|
|
||||||
<PopoverItem
|
|
||||||
key={workspace.id}
|
|
||||||
active={currentWorkspaceId === workspace.id}
|
|
||||||
showCheck
|
|
||||||
onClick={() => handleWorkspaceChange(workspace.id)}
|
|
||||||
>
|
|
||||||
{workspace.name}
|
|
||||||
</PopoverItem>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{workspaces.length === 0 && !isLoading && (
|
|
||||||
<PopoverItem disabled>
|
|
||||||
<span className='text-muted-foreground text-xs'>No workspaces with write access</span>
|
|
||||||
</PopoverItem>
|
|
||||||
)}
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react'
|
import { useCallback, useState } from 'react'
|
||||||
import { createLogger } from '@/lib/logs/console/logger'
|
import { createLogger } from '@/lib/logs/console/logger'
|
||||||
|
|
||||||
const logger = createLogger('KnowledgeUpload')
|
const logger = createLogger('KnowledgeUpload')
|
||||||
@@ -84,15 +84,18 @@ class ProcessingError extends KnowledgeUploadError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration constants for file upload operations
|
||||||
|
*/
|
||||||
const UPLOAD_CONFIG = {
|
const UPLOAD_CONFIG = {
|
||||||
MAX_PARALLEL_UPLOADS: 3, // Prevent client saturation – mirrors guidance on limiting simultaneous transfers (@Web)
|
MAX_PARALLEL_UPLOADS: 3,
|
||||||
MAX_RETRIES: 3,
|
MAX_RETRIES: 3,
|
||||||
RETRY_DELAY_MS: 2000,
|
RETRY_DELAY_MS: 2000,
|
||||||
RETRY_BACKOFF: 2,
|
RETRY_BACKOFF: 2,
|
||||||
CHUNK_SIZE: 8 * 1024 * 1024, // 8MB keeps us well above S3 minimum part size while reducing part count (@Web)
|
CHUNK_SIZE: 8 * 1024 * 1024,
|
||||||
DIRECT_UPLOAD_THRESHOLD: 4 * 1024 * 1024,
|
DIRECT_UPLOAD_THRESHOLD: 4 * 1024 * 1024,
|
||||||
LARGE_FILE_THRESHOLD: 50 * 1024 * 1024,
|
LARGE_FILE_THRESHOLD: 50 * 1024 * 1024,
|
||||||
BASE_TIMEOUT_MS: 2 * 60 * 1000, // baseline per transfer window per large-file guidance (@Web)
|
BASE_TIMEOUT_MS: 2 * 60 * 1000,
|
||||||
TIMEOUT_PER_MB_MS: 1500,
|
TIMEOUT_PER_MB_MS: 1500,
|
||||||
MAX_TIMEOUT_MS: 10 * 60 * 1000,
|
MAX_TIMEOUT_MS: 10 * 60 * 1000,
|
||||||
MULTIPART_PART_CONCURRENCY: 3,
|
MULTIPART_PART_CONCURRENCY: 3,
|
||||||
@@ -100,28 +103,49 @@ const UPLOAD_CONFIG = {
|
|||||||
BATCH_REQUEST_SIZE: 50,
|
BATCH_REQUEST_SIZE: 50,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the upload timeout based on file size
|
||||||
|
*/
|
||||||
const calculateUploadTimeoutMs = (fileSize: number) => {
|
const calculateUploadTimeoutMs = (fileSize: number) => {
|
||||||
const sizeInMb = fileSize / (1024 * 1024)
|
const sizeInMb = fileSize / (1024 * 1024)
|
||||||
const dynamicBudget = UPLOAD_CONFIG.BASE_TIMEOUT_MS + sizeInMb * UPLOAD_CONFIG.TIMEOUT_PER_MB_MS
|
const dynamicBudget = UPLOAD_CONFIG.BASE_TIMEOUT_MS + sizeInMb * UPLOAD_CONFIG.TIMEOUT_PER_MB_MS
|
||||||
return Math.min(dynamicBudget, UPLOAD_CONFIG.MAX_TIMEOUT_MS)
|
return Math.min(dynamicBudget, UPLOAD_CONFIG.MAX_TIMEOUT_MS)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delays execution for the specified duration
|
||||||
|
*/
|
||||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets high resolution timestamp for performance measurements
|
||||||
|
*/
|
||||||
const getHighResTime = () =>
|
const getHighResTime = () =>
|
||||||
typeof performance !== 'undefined' && typeof performance.now === 'function'
|
typeof performance !== 'undefined' && typeof performance.now === 'function'
|
||||||
? performance.now()
|
? performance.now()
|
||||||
: Date.now()
|
: Date.now()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats bytes to megabytes with 2 decimal places
|
||||||
|
*/
|
||||||
const formatMegabytes = (bytes: number) => Number((bytes / (1024 * 1024)).toFixed(2))
|
const formatMegabytes = (bytes: number) => Number((bytes / (1024 * 1024)).toFixed(2))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates throughput in Mbps
|
||||||
|
*/
|
||||||
const calculateThroughputMbps = (bytes: number, durationMs: number) => {
|
const calculateThroughputMbps = (bytes: number, durationMs: number) => {
|
||||||
if (!bytes || !durationMs) return 0
|
if (!bytes || !durationMs) return 0
|
||||||
return Number((((bytes * 8) / durationMs) * 0.001).toFixed(2))
|
return Number((((bytes * 8) / durationMs) * 0.001).toFixed(2))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats duration from milliseconds to seconds
|
||||||
|
*/
|
||||||
const formatDurationSeconds = (durationMs: number) => Number((durationMs / 1000).toFixed(2))
|
const formatDurationSeconds = (durationMs: number) => Number((durationMs / 1000).toFixed(2))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs async operations with concurrency limit
|
||||||
|
*/
|
||||||
const runWithConcurrency = async <T, R>(
|
const runWithConcurrency = async <T, R>(
|
||||||
items: T[],
|
items: T[],
|
||||||
limit: number,
|
limit: number,
|
||||||
@@ -156,14 +180,26 @@ const runWithConcurrency = async <T, R>(
|
|||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the error name from an unknown error object
|
||||||
|
*/
|
||||||
const getErrorName = (error: unknown) =>
|
const getErrorName = (error: unknown) =>
|
||||||
typeof error === 'object' && error !== null && 'name' in error ? String((error as any).name) : ''
|
typeof error === 'object' && error !== null && 'name' in error ? String((error as any).name) : ''
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts a human-readable message from an unknown error
|
||||||
|
*/
|
||||||
const getErrorMessage = (error: unknown) =>
|
const getErrorMessage = (error: unknown) =>
|
||||||
error instanceof Error ? error.message : typeof error === 'string' ? error : 'Unknown error'
|
error instanceof Error ? error.message : typeof error === 'string' ? error : 'Unknown error'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if an error is an abort error
|
||||||
|
*/
|
||||||
const isAbortError = (error: unknown) => getErrorName(error) === 'AbortError'
|
const isAbortError = (error: unknown) => getErrorName(error) === 'AbortError'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if an error is a network-related error
|
||||||
|
*/
|
||||||
const isNetworkError = (error: unknown) => {
|
const isNetworkError = (error: unknown) => {
|
||||||
if (!(error instanceof Error)) {
|
if (!(error instanceof Error)) {
|
||||||
return false
|
return false
|
||||||
@@ -197,6 +233,9 @@ interface PresignedUploadInfo {
|
|||||||
presignedUrls?: any
|
presignedUrls?: any
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes presigned URL response data into a consistent format
|
||||||
|
*/
|
||||||
const normalizePresignedData = (data: any, context: string): PresignedUploadInfo => {
|
const normalizePresignedData = (data: any, context: string): PresignedUploadInfo => {
|
||||||
const presignedUrl = data?.presignedUrl || data?.uploadUrl
|
const presignedUrl = data?.presignedUrl || data?.uploadUrl
|
||||||
const fileInfo = data?.fileInfo
|
const fileInfo = data?.fileInfo
|
||||||
@@ -221,6 +260,9 @@ const normalizePresignedData = (data: any, context: string): PresignedUploadInfo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches presigned URL data for file upload
|
||||||
|
*/
|
||||||
const getPresignedData = async (
|
const getPresignedData = async (
|
||||||
file: File,
|
file: File,
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
@@ -249,7 +291,7 @@ const getPresignedData = async (
|
|||||||
try {
|
try {
|
||||||
errorDetails = await presignedResponse.json()
|
errorDetails = await presignedResponse.json()
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore JSON parsing errors (@Web)
|
errorDetails = null
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.error('Presigned URL request failed', {
|
logger.error('Presigned URL request failed', {
|
||||||
@@ -279,6 +321,9 @@ const getPresignedData = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for managing file uploads to knowledge bases
|
||||||
|
*/
|
||||||
export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
|
export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
|
||||||
const [isUploading, setIsUploading] = useState(false)
|
const [isUploading, setIsUploading] = useState(false)
|
||||||
const [uploadProgress, setUploadProgress] = useState<UploadProgress>({
|
const [uploadProgress, setUploadProgress] = useState<UploadProgress>({
|
||||||
@@ -288,6 +333,9 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
|
|||||||
})
|
})
|
||||||
const [uploadError, setUploadError] = useState<UploadError | null>(null)
|
const [uploadError, setUploadError] = useState<UploadError | null>(null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an UploadedFile object from file metadata
|
||||||
|
*/
|
||||||
const createUploadedFile = (
|
const createUploadedFile = (
|
||||||
filename: string,
|
filename: string,
|
||||||
fileUrl: string,
|
fileUrl: string,
|
||||||
@@ -299,7 +347,6 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
|
|||||||
fileUrl,
|
fileUrl,
|
||||||
fileSize,
|
fileSize,
|
||||||
mimeType,
|
mimeType,
|
||||||
// Include tags from original file if available
|
|
||||||
tag1: (originalFile as any)?.tag1,
|
tag1: (originalFile as any)?.tag1,
|
||||||
tag2: (originalFile as any)?.tag2,
|
tag2: (originalFile as any)?.tag2,
|
||||||
tag3: (originalFile as any)?.tag3,
|
tag3: (originalFile as any)?.tag3,
|
||||||
@@ -309,6 +356,9 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
|
|||||||
tag7: (originalFile as any)?.tag7,
|
tag7: (originalFile as any)?.tag7,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an UploadError from an exception
|
||||||
|
*/
|
||||||
const createErrorFromException = (error: unknown, defaultMessage: string): UploadError => {
|
const createErrorFromException = (error: unknown, defaultMessage: string): UploadError => {
|
||||||
if (error instanceof KnowledgeUploadError) {
|
if (error instanceof KnowledgeUploadError) {
|
||||||
return {
|
return {
|
||||||
@@ -356,13 +406,11 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
|
|||||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// For large files (>50MB), use multipart upload
|
|
||||||
if (file.size > UPLOAD_CONFIG.LARGE_FILE_THRESHOLD) {
|
if (file.size > UPLOAD_CONFIG.LARGE_FILE_THRESHOLD) {
|
||||||
presignedData = presignedOverride ?? (await getPresignedData(file, timeoutMs, controller))
|
presignedData = presignedOverride ?? (await getPresignedData(file, timeoutMs, controller))
|
||||||
return await uploadFileInChunks(file, presignedData, timeoutMs, fileIndex)
|
return await uploadFileInChunks(file, presignedData, timeoutMs, fileIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
// For all other files, use server-side upload
|
|
||||||
return await uploadFileThroughAPI(file, timeoutMs)
|
return await uploadFileThroughAPI(file, timeoutMs)
|
||||||
} finally {
|
} finally {
|
||||||
clearTimeout(timeoutId)
|
clearTimeout(timeoutId)
|
||||||
@@ -372,7 +420,7 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
|
|||||||
const isNetwork = isNetworkError(error)
|
const isNetwork = isNetworkError(error)
|
||||||
|
|
||||||
if (retryCount < UPLOAD_CONFIG.MAX_RETRIES) {
|
if (retryCount < UPLOAD_CONFIG.MAX_RETRIES) {
|
||||||
const delay = UPLOAD_CONFIG.RETRY_DELAY_MS * UPLOAD_CONFIG.RETRY_BACKOFF ** retryCount // More aggressive exponential backoff (@Web)
|
const delay = UPLOAD_CONFIG.RETRY_DELAY_MS * UPLOAD_CONFIG.RETRY_BACKOFF ** retryCount
|
||||||
if (isTimeout || isNetwork) {
|
if (isTimeout || isNetwork) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Upload failed (${isTimeout ? 'timeout' : 'network'}), retrying in ${delay / 1000}s...`,
|
`Upload failed (${isTimeout ? 'timeout' : 'network'}), retrying in ${delay / 1000}s...`,
|
||||||
@@ -446,7 +494,6 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
|
|||||||
|
|
||||||
outerController.signal.addEventListener('abort', abortHandler)
|
outerController.signal.addEventListener('abort', abortHandler)
|
||||||
|
|
||||||
// Track upload progress
|
|
||||||
xhr.upload.addEventListener('progress', (event) => {
|
xhr.upload.addEventListener('progress', (event) => {
|
||||||
if (event.lengthComputable && fileIndex !== undefined && !isCompleted) {
|
if (event.lengthComputable && fileIndex !== undefined && !isCompleted) {
|
||||||
const percentComplete = Math.round((event.loaded / event.total) * 100)
|
const percentComplete = Math.round((event.loaded / event.total) * 100)
|
||||||
@@ -517,10 +564,8 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
|
|||||||
|
|
||||||
xhr.addEventListener('abort', abortHandler)
|
xhr.addEventListener('abort', abortHandler)
|
||||||
|
|
||||||
// Start the upload
|
|
||||||
xhr.open('PUT', presignedData.presignedUrl)
|
xhr.open('PUT', presignedData.presignedUrl)
|
||||||
|
|
||||||
// Set headers
|
|
||||||
xhr.setRequestHeader('Content-Type', file.type)
|
xhr.setRequestHeader('Content-Type', file.type)
|
||||||
if (presignedData.uploadHeaders) {
|
if (presignedData.uploadHeaders) {
|
||||||
Object.entries(presignedData.uploadHeaders).forEach(([key, value]) => {
|
Object.entries(presignedData.uploadHeaders).forEach(([key, value]) => {
|
||||||
@@ -547,7 +592,6 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
|
|||||||
const startTime = getHighResTime()
|
const startTime = getHighResTime()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Step 1: Initiate multipart upload
|
|
||||||
const initiateResponse = await fetch('/api/files/multipart?action=initiate', {
|
const initiateResponse = await fetch('/api/files/multipart?action=initiate', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -565,12 +609,10 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
|
|||||||
const { uploadId, key } = await initiateResponse.json()
|
const { uploadId, key } = await initiateResponse.json()
|
||||||
logger.info(`Initiated multipart upload with ID: ${uploadId}`)
|
logger.info(`Initiated multipart upload with ID: ${uploadId}`)
|
||||||
|
|
||||||
// Step 2: Calculate parts
|
|
||||||
const chunkSize = UPLOAD_CONFIG.CHUNK_SIZE
|
const chunkSize = UPLOAD_CONFIG.CHUNK_SIZE
|
||||||
const numParts = Math.ceil(file.size / chunkSize)
|
const numParts = Math.ceil(file.size / chunkSize)
|
||||||
const partNumbers = Array.from({ length: numParts }, (_, i) => i + 1)
|
const partNumbers = Array.from({ length: numParts }, (_, i) => i + 1)
|
||||||
|
|
||||||
// Step 3: Get presigned URLs for all parts
|
|
||||||
const partUrlsResponse = await fetch('/api/files/multipart?action=get-part-urls', {
|
const partUrlsResponse = await fetch('/api/files/multipart?action=get-part-urls', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -593,7 +635,6 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
|
|||||||
|
|
||||||
const { presignedUrls } = await partUrlsResponse.json()
|
const { presignedUrls } = await partUrlsResponse.json()
|
||||||
|
|
||||||
// Step 4: Upload parts in parallel (batch them to avoid overwhelming the browser)
|
|
||||||
const uploadedParts: Array<{ ETag: string; PartNumber: number }> = []
|
const uploadedParts: Array<{ ETag: string; PartNumber: number }> = []
|
||||||
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
@@ -667,7 +708,6 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
|
|||||||
clearTimeout(multipartTimeoutId)
|
clearTimeout(multipartTimeoutId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 5: Complete multipart upload
|
|
||||||
const completeResponse = await fetch('/api/files/multipart?action=complete', {
|
const completeResponse = await fetch('/api/files/multipart?action=complete', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -705,7 +745,6 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
|
|||||||
sizeMB: formatMegabytes(file.size),
|
sizeMB: formatMegabytes(file.size),
|
||||||
durationMs: formatDurationSeconds(durationMs),
|
durationMs: formatDurationSeconds(durationMs),
|
||||||
})
|
})
|
||||||
// Fall back to direct upload if multipart fails
|
|
||||||
return uploadFileDirectly(file, presignedData, timeoutMs, new AbortController(), fileIndex)
|
return uploadFileDirectly(file, presignedData, timeoutMs, new AbortController(), fileIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -737,7 +776,7 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
|
|||||||
try {
|
try {
|
||||||
errorData = await uploadResponse.json()
|
errorData = await uploadResponse.json()
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore JSON parsing errors
|
errorData = null
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new DirectUploadError(
|
throw new DirectUploadError(
|
||||||
@@ -772,11 +811,13 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
|
|||||||
/**
|
/**
|
||||||
* Upload files using batch presigned URLs (works for both S3 and Azure Blob)
|
* Upload files using batch presigned URLs (works for both S3 and Azure Blob)
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Uploads files in batches using presigned URLs
|
||||||
|
*/
|
||||||
const uploadFilesInBatches = async (files: File[]): Promise<UploadedFile[]> => {
|
const uploadFilesInBatches = async (files: File[]): Promise<UploadedFile[]> => {
|
||||||
const results: UploadedFile[] = []
|
const results: UploadedFile[] = []
|
||||||
const failedFiles: Array<{ file: File; error: Error }> = []
|
const failedFiles: Array<{ file: File; error: Error }> = []
|
||||||
|
|
||||||
// Initialize file statuses
|
|
||||||
const fileStatuses: FileUploadStatus[] = files.map((file) => ({
|
const fileStatuses: FileUploadStatus[] = files.map((file) => ({
|
||||||
fileName: file.name,
|
fileName: file.name,
|
||||||
fileSize: file.size,
|
fileSize: file.size,
|
||||||
@@ -925,6 +966,9 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main upload function that handles file uploads and document processing
|
||||||
|
*/
|
||||||
const uploadFiles = async (
|
const uploadFiles = async (
|
||||||
files: File[],
|
files: File[],
|
||||||
knowledgeBaseId: string,
|
knowledgeBaseId: string,
|
||||||
@@ -950,7 +994,6 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
|
|||||||
const processPayload = {
|
const processPayload = {
|
||||||
documents: uploadedFiles.map((file) => ({
|
documents: uploadedFiles.map((file) => ({
|
||||||
...file,
|
...file,
|
||||||
// Tags are already included in the file object from createUploadedFile
|
|
||||||
})),
|
})),
|
||||||
processingOptions: {
|
processingOptions: {
|
||||||
chunkSize: processingOptions.chunkSize || 1024,
|
chunkSize: processingOptions.chunkSize || 1024,
|
||||||
@@ -975,7 +1018,7 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
|
|||||||
try {
|
try {
|
||||||
errorData = await processResponse.json()
|
errorData = await processResponse.json()
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore JSON parsing errors
|
errorData = null
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.error('Document processing failed:', {
|
logger.error('Document processing failed:', {
|
||||||
@@ -1034,9 +1077,12 @@ export function useKnowledgeUpload(options: UseKnowledgeUploadOptions = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearError = () => {
|
/**
|
||||||
|
* Clears the current upload error
|
||||||
|
*/
|
||||||
|
const clearError = useCallback(() => {
|
||||||
setUploadError(null)
|
setUploadError(null)
|
||||||
}
|
}, [])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isUploading,
|
isUploading,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { ChevronDown, LibraryBig, Plus } from 'lucide-react'
|
import { ChevronDown, Database, Search } from 'lucide-react'
|
||||||
import { useParams } from 'next/navigation'
|
import { useParams } from 'next/navigation'
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -11,32 +11,37 @@ import {
|
|||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from '@/components/emcn'
|
} from '@/components/emcn'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
import {
|
import {
|
||||||
BaseOverview,
|
BaseCard,
|
||||||
CreateModal,
|
BaseCardSkeletonGrid,
|
||||||
EmptyStateCard,
|
CreateBaseModal,
|
||||||
KnowledgeBaseCardSkeletonGrid,
|
|
||||||
KnowledgeHeader,
|
|
||||||
SearchInput,
|
|
||||||
} from '@/app/workspace/[workspaceId]/knowledge/components'
|
} from '@/app/workspace/[workspaceId]/knowledge/components'
|
||||||
import {
|
import {
|
||||||
filterButtonClass,
|
|
||||||
SORT_OPTIONS,
|
SORT_OPTIONS,
|
||||||
type SortOption,
|
type SortOption,
|
||||||
type SortOrder,
|
type SortOrder,
|
||||||
} from '@/app/workspace/[workspaceId]/knowledge/components/shared'
|
} from '@/app/workspace/[workspaceId]/knowledge/components/constants'
|
||||||
import {
|
import {
|
||||||
filterKnowledgeBases,
|
filterKnowledgeBases,
|
||||||
sortKnowledgeBases,
|
sortKnowledgeBases,
|
||||||
} from '@/app/workspace/[workspaceId]/knowledge/utils/sort'
|
} from '@/app/workspace/[workspaceId]/knowledge/utils/sort'
|
||||||
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||||
|
import { useDebounce } from '@/hooks/use-debounce'
|
||||||
import { useKnowledgeBasesList } from '@/hooks/use-knowledge'
|
import { useKnowledgeBasesList } from '@/hooks/use-knowledge'
|
||||||
import type { KnowledgeBaseData } from '@/stores/knowledge/store'
|
import type { KnowledgeBaseData } from '@/stores/knowledge/store'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extended knowledge base data with document count
|
||||||
|
*/
|
||||||
interface KnowledgeBaseWithDocCount extends KnowledgeBaseData {
|
interface KnowledgeBaseWithDocCount extends KnowledgeBaseData {
|
||||||
docCount?: number
|
docCount?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Knowledge base list component displaying all knowledge bases in a workspace
|
||||||
|
* Supports filtering by search query and sorting options
|
||||||
|
*/
|
||||||
export function Knowledge() {
|
export function Knowledge() {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const workspaceId = params.workspaceId as string
|
const workspaceId = params.workspaceId as string
|
||||||
@@ -46,6 +51,7 @@ export function Knowledge() {
|
|||||||
const userPermissions = useUserPermissionsContext()
|
const userPermissions = useUserPermissionsContext()
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const debouncedSearchQuery = useDebounce(searchQuery, 300)
|
||||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false)
|
||||||
const [isSortPopoverOpen, setIsSortPopoverOpen] = useState(false)
|
const [isSortPopoverOpen, setIsSortPopoverOpen] = useState(false)
|
||||||
const [sortBy, setSortBy] = useState<SortOption>('updatedAt')
|
const [sortBy, setSortBy] = useState<SortOption>('updatedAt')
|
||||||
@@ -55,6 +61,9 @@ export function Knowledge() {
|
|||||||
const currentSortLabel =
|
const currentSortLabel =
|
||||||
SORT_OPTIONS.find((opt) => opt.value === currentSortValue)?.label || 'Last Updated'
|
SORT_OPTIONS.find((opt) => opt.value === currentSortValue)?.label || 'Last Updated'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles sort option change from dropdown
|
||||||
|
*/
|
||||||
const handleSortChange = (value: string) => {
|
const handleSortChange = (value: string) => {
|
||||||
const [field, order] = value.split('-') as [SortOption, SortOrder]
|
const [field, order] = value.split('-') as [SortOption, SortOrder]
|
||||||
setSortBy(field)
|
setSortBy(field)
|
||||||
@@ -62,19 +71,32 @@ export function Knowledge() {
|
|||||||
setIsSortPopoverOpen(false)
|
setIsSortPopoverOpen(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback when a new knowledge base is created
|
||||||
|
*/
|
||||||
const handleKnowledgeBaseCreated = (newKnowledgeBase: KnowledgeBaseData) => {
|
const handleKnowledgeBaseCreated = (newKnowledgeBase: KnowledgeBaseData) => {
|
||||||
addKnowledgeBase(newKnowledgeBase)
|
addKnowledgeBase(newKnowledgeBase)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry loading knowledge bases after an error
|
||||||
|
*/
|
||||||
const handleRetry = () => {
|
const handleRetry = () => {
|
||||||
refreshList()
|
refreshList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter and sort knowledge bases based on search query and sort options
|
||||||
|
* Memoized to prevent unnecessary recalculations on render
|
||||||
|
*/
|
||||||
const filteredAndSortedKnowledgeBases = useMemo(() => {
|
const filteredAndSortedKnowledgeBases = useMemo(() => {
|
||||||
const filtered = filterKnowledgeBases(knowledgeBases, searchQuery)
|
const filtered = filterKnowledgeBases(knowledgeBases, debouncedSearchQuery)
|
||||||
return sortKnowledgeBases(filtered, sortBy, sortOrder)
|
return sortKnowledgeBases(filtered, sortBy, sortOrder)
|
||||||
}, [knowledgeBases, searchQuery, sortBy, sortOrder])
|
}, [knowledgeBases, debouncedSearchQuery, sortBy, sortOrder])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format knowledge base data for display in the card
|
||||||
|
*/
|
||||||
const formatKnowledgeBaseForDisplay = (kb: KnowledgeBaseWithDocCount) => ({
|
const formatKnowledgeBaseForDisplay = (kb: KnowledgeBaseWithDocCount) => ({
|
||||||
id: kb.id,
|
id: kb.id,
|
||||||
title: kb.name,
|
title: kb.name,
|
||||||
@@ -84,146 +106,144 @@ export function Knowledge() {
|
|||||||
updatedAt: kb.updatedAt,
|
updatedAt: kb.updatedAt,
|
||||||
})
|
})
|
||||||
|
|
||||||
const breadcrumbs = [{ id: 'knowledge', label: 'Knowledge' }]
|
/**
|
||||||
|
* Get empty state content based on current filters
|
||||||
|
* Memoized to prevent unnecessary recalculations on render
|
||||||
|
*/
|
||||||
|
const emptyState = useMemo(() => {
|
||||||
|
if (debouncedSearchQuery) {
|
||||||
|
return {
|
||||||
|
title: 'No knowledge bases found',
|
||||||
|
description: 'Try a different search term',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: 'No knowledge bases yet',
|
||||||
|
description:
|
||||||
|
userPermissions.canEdit === true
|
||||||
|
? 'Create a knowledge base to get started'
|
||||||
|
: 'Knowledge bases will appear here once created',
|
||||||
|
}
|
||||||
|
}, [debouncedSearchQuery, userPermissions.canEdit])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className='flex h-screen flex-col pl-64'>
|
<div className='flex h-full flex-1 flex-col'>
|
||||||
{/* Header */}
|
|
||||||
<KnowledgeHeader breadcrumbs={breadcrumbs} />
|
|
||||||
|
|
||||||
<div className='flex flex-1 overflow-hidden'>
|
<div className='flex flex-1 overflow-hidden'>
|
||||||
<div className='flex flex-1 flex-col overflow-hidden'>
|
<div className='flex flex-1 flex-col overflow-auto px-[24px] pt-[28px] pb-[24px]'>
|
||||||
{/* Main Content */}
|
<div>
|
||||||
<div className='flex-1 overflow-auto'>
|
<div className='flex items-start gap-[12px]'>
|
||||||
<div className='px-6 pb-6'>
|
<div className='flex h-[26px] w-[26px] items-center justify-center rounded-[6px] border border-[#1E5A3E] bg-[#0F3D2C]'>
|
||||||
{/* Search and Create Section */}
|
<Database className='h-[14px] w-[14px] text-[#34D399]' />
|
||||||
<div className='mb-4 flex items-center justify-between pt-1'>
|
</div>
|
||||||
<SearchInput
|
<h1 className='font-medium text-[18px]'>Knowledge Base</h1>
|
||||||
value={searchQuery}
|
</div>
|
||||||
onChange={setSearchQuery}
|
<p className='mt-[10px] text-[14px] text-[var(--text-tertiary)]'>
|
||||||
placeholder='Search knowledge bases...'
|
Create and manage knowledge bases with custom files.
|
||||||
/>
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className='flex items-center gap-2'>
|
<div className='mt-[14px] flex items-center justify-between'>
|
||||||
{/* Sort Dropdown */}
|
<div className='flex h-[32px] w-[400px] items-center gap-[6px] rounded-[8px] bg-[var(--surface-5)] px-[8px]'>
|
||||||
<Popover open={isSortPopoverOpen} onOpenChange={setIsSortPopoverOpen}>
|
<Search className='h-[14px] w-[14px] text-[var(--text-subtle)]' />
|
||||||
<PopoverTrigger asChild>
|
<Input
|
||||||
<Button variant='outline' className={filterButtonClass}>
|
placeholder='Search'
|
||||||
{currentSortLabel}
|
value={searchQuery}
|
||||||
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
</Button>
|
className='flex-1 border-0 bg-transparent px-0 font-medium text-[var(--text-secondary)] text-small leading-none placeholder:text-[var(--text-subtle)] focus-visible:ring-0 focus-visible:ring-offset-0'
|
||||||
</PopoverTrigger>
|
/>
|
||||||
<PopoverContent align='end' side='bottom' sideOffset={4}>
|
</div>
|
||||||
|
<div className='flex items-center gap-[8px]'>
|
||||||
|
{knowledgeBases.length > 0 && (
|
||||||
|
<Popover open={isSortPopoverOpen} onOpenChange={setIsSortPopoverOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant='default' className='h-[32px] rounded-[6px]'>
|
||||||
|
{currentSortLabel}
|
||||||
|
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align='end' side='bottom' sideOffset={4}>
|
||||||
|
<div className='flex flex-col gap-[2px]'>
|
||||||
{SORT_OPTIONS.map((option) => (
|
{SORT_OPTIONS.map((option) => (
|
||||||
<PopoverItem
|
<PopoverItem
|
||||||
key={option.value}
|
key={option.value}
|
||||||
active={currentSortValue === option.value}
|
active={currentSortValue === option.value}
|
||||||
showCheck
|
|
||||||
onClick={() => handleSortChange(option.value)}
|
onClick={() => handleSortChange(option.value)}
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
</PopoverItem>
|
</PopoverItem>
|
||||||
))}
|
))}
|
||||||
</PopoverContent>
|
</div>
|
||||||
</Popover>
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Create Button */}
|
<Tooltip.Root>
|
||||||
<Tooltip.Root>
|
<Tooltip.Trigger asChild>
|
||||||
<Tooltip.Trigger asChild>
|
<Button
|
||||||
<Button
|
onClick={() => setIsCreateModalOpen(true)}
|
||||||
onClick={() => setIsCreateModalOpen(true)}
|
disabled={userPermissions.canEdit !== true}
|
||||||
disabled={userPermissions.canEdit !== true}
|
variant='primary'
|
||||||
variant='primary'
|
className='h-[32px] rounded-[6px]'
|
||||||
className='flex items-center gap-1'
|
>
|
||||||
>
|
Create
|
||||||
<Plus className='h-3.5 w-3.5' />
|
</Button>
|
||||||
<span>Create</span>
|
</Tooltip.Trigger>
|
||||||
</Button>
|
{userPermissions.canEdit !== true && (
|
||||||
</Tooltip.Trigger>
|
<Tooltip.Content>
|
||||||
{userPermissions.canEdit !== true && (
|
Write permission required to create knowledge bases
|
||||||
<Tooltip.Content>
|
</Tooltip.Content>
|
||||||
Write permission required to create knowledge bases
|
)}
|
||||||
</Tooltip.Content>
|
</Tooltip.Root>
|
||||||
)}
|
</div>
|
||||||
</Tooltip.Root>
|
</div>
|
||||||
|
|
||||||
|
<div className='mt-[24px] grid grid-cols-1 gap-x-[20px] gap-y-[40px] md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
|
||||||
|
{isLoading ? (
|
||||||
|
<BaseCardSkeletonGrid count={8} />
|
||||||
|
) : filteredAndSortedKnowledgeBases.length === 0 ? (
|
||||||
|
<div className='col-span-full flex h-64 items-center justify-center rounded-lg border border-muted-foreground/25 bg-muted/20'>
|
||||||
|
<div className='text-center'>
|
||||||
|
<p className='font-medium text-[var(--text-secondary)] text-sm'>
|
||||||
|
{emptyState.title}
|
||||||
|
</p>
|
||||||
|
<p className='mt-1 text-[var(--text-muted)] text-xs'>
|
||||||
|
{emptyState.description}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : error ? (
|
||||||
{/* Error State */}
|
<div className='col-span-full flex h-64 items-center justify-center rounded-lg border border-muted-foreground/25 bg-muted/20'>
|
||||||
{error && (
|
<div className='text-center'>
|
||||||
<div className='mb-4 rounded-md border border-red-200 bg-red-50 p-4'>
|
<p className='font-medium text-[var(--text-secondary)] text-sm'>
|
||||||
<p className='text-red-800 text-sm'>Error loading knowledge bases: {error}</p>
|
Error loading knowledge bases
|
||||||
<button
|
</p>
|
||||||
onClick={handleRetry}
|
<p className='mt-1 text-[var(--text-muted)] text-xs'>{error}</p>
|
||||||
className='mt-2 text-red-600 text-sm underline hover:text-red-800'
|
|
||||||
>
|
|
||||||
Try again
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
) : (
|
||||||
{/* Content Area */}
|
filteredAndSortedKnowledgeBases.map((kb) => {
|
||||||
{isLoading ? (
|
const displayData = formatKnowledgeBaseForDisplay(kb as KnowledgeBaseWithDocCount)
|
||||||
<KnowledgeBaseCardSkeletonGrid count={8} />
|
return (
|
||||||
) : (
|
<BaseCard
|
||||||
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'>
|
key={kb.id}
|
||||||
{filteredAndSortedKnowledgeBases.length === 0 ? (
|
id={displayData.id}
|
||||||
knowledgeBases.length === 0 ? (
|
title={displayData.title}
|
||||||
<EmptyStateCard
|
docCount={displayData.docCount}
|
||||||
title='Create your first knowledge base'
|
description={displayData.description}
|
||||||
description={
|
createdAt={displayData.createdAt}
|
||||||
userPermissions.canEdit === true
|
updatedAt={displayData.updatedAt}
|
||||||
? 'Upload your documents to create a knowledge base for your agents.'
|
/>
|
||||||
: 'Knowledge bases will appear here. Contact an admin to create knowledge bases.'
|
)
|
||||||
}
|
})
|
||||||
buttonText={
|
)}
|
||||||
userPermissions.canEdit === true
|
|
||||||
? 'Create Knowledge Base'
|
|
||||||
: 'Contact Admin'
|
|
||||||
}
|
|
||||||
onClick={
|
|
||||||
userPermissions.canEdit === true
|
|
||||||
? () => setIsCreateModalOpen(true)
|
|
||||||
: () => {}
|
|
||||||
}
|
|
||||||
icon={<LibraryBig className='h-4 w-4 text-muted-foreground' />}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className='col-span-full py-12 text-center'>
|
|
||||||
<p className='text-muted-foreground'>
|
|
||||||
No knowledge bases match your search.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
filteredAndSortedKnowledgeBases.map((kb) => {
|
|
||||||
const displayData = formatKnowledgeBaseForDisplay(
|
|
||||||
kb as KnowledgeBaseWithDocCount
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
<BaseOverview
|
|
||||||
key={kb.id}
|
|
||||||
id={displayData.id}
|
|
||||||
title={displayData.title}
|
|
||||||
docCount={displayData.docCount}
|
|
||||||
description={displayData.description}
|
|
||||||
createdAt={displayData.createdAt}
|
|
||||||
updatedAt={displayData.updatedAt}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Create Modal */}
|
<CreateBaseModal
|
||||||
<CreateModal
|
|
||||||
open={isCreateModalOpen}
|
open={isCreateModalOpen}
|
||||||
onOpenChange={setIsCreateModalOpen}
|
onOpenChange={setIsCreateModalOpen}
|
||||||
onKnowledgeBaseCreated={handleKnowledgeBaseCreated}
|
onKnowledgeBaseCreated={handleKnowledgeBaseCreated}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Knowledge Base layout - applies sidebar padding for all knowledge routes.
|
||||||
|
*/
|
||||||
|
export default function KnowledgeLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return <div className='flex h-full flex-1 flex-col pl-60'>{children}</div>
|
||||||
|
}
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { ChevronDown } from 'lucide-react'
|
|
||||||
import { Button, Popover, PopoverAnchor, PopoverContent, PopoverItem } from '@/components/emcn'
|
|
||||||
import {
|
|
||||||
KnowledgeBaseCardSkeletonGrid,
|
|
||||||
KnowledgeHeader,
|
|
||||||
SearchInput,
|
|
||||||
} from '@/app/workspace/[workspaceId]/knowledge/components'
|
|
||||||
import {
|
|
||||||
filterButtonClass,
|
|
||||||
SORT_OPTIONS,
|
|
||||||
} from '@/app/workspace/[workspaceId]/knowledge/components/shared'
|
|
||||||
|
|
||||||
export default function KnowledgeLoading() {
|
|
||||||
const breadcrumbs = [{ id: 'knowledge', label: 'Knowledge' }]
|
|
||||||
const currentSortLabel = SORT_OPTIONS[0]?.label || 'Last Updated'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='flex h-screen flex-col pl-64'>
|
|
||||||
{/* Header */}
|
|
||||||
<KnowledgeHeader breadcrumbs={breadcrumbs} />
|
|
||||||
|
|
||||||
<div className='flex flex-1 overflow-hidden'>
|
|
||||||
<div className='flex flex-1 flex-col overflow-hidden'>
|
|
||||||
{/* Main Content */}
|
|
||||||
<div className='flex-1 overflow-auto'>
|
|
||||||
<div className='px-6 pb-6'>
|
|
||||||
{/* Search and Create Section */}
|
|
||||||
<div className='mb-4 flex items-center justify-between pt-1'>
|
|
||||||
<SearchInput
|
|
||||||
value=''
|
|
||||||
onChange={() => {}}
|
|
||||||
placeholder='Search knowledge bases...'
|
|
||||||
disabled
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
{/* Sort Dropdown */}
|
|
||||||
<Popover open={false}>
|
|
||||||
<PopoverAnchor asChild>
|
|
||||||
<Button variant='outline' className={filterButtonClass} disabled>
|
|
||||||
{currentSortLabel}
|
|
||||||
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
|
|
||||||
</Button>
|
|
||||||
</PopoverAnchor>
|
|
||||||
<PopoverContent align='end' side='bottom' sideOffset={4}>
|
|
||||||
{SORT_OPTIONS.map((option) => (
|
|
||||||
<PopoverItem key={option.value} disabled>
|
|
||||||
{option.label}
|
|
||||||
</PopoverItem>
|
|
||||||
))}
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
|
|
||||||
{/* Create Button */}
|
|
||||||
<Button disabled variant='primary' className='flex items-center gap-1'>
|
|
||||||
<div className='h-3.5 w-3.5 animate-pulse rounded bg-primary-foreground/30' />
|
|
||||||
<span>Create</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content Area */}
|
|
||||||
<KnowledgeBaseCardSkeletonGrid count={8} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,5 +1 @@
|
|||||||
import { Knowledge } from '@/app/workspace/[workspaceId]/knowledge/knowledge'
|
export { Knowledge as default } from './knowledge'
|
||||||
|
|
||||||
export default function KnowledgePage() {
|
|
||||||
return <Knowledge />
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { KnowledgeBaseData } from '@/stores/knowledge/store'
|
import type { KnowledgeBaseData } from '@/stores/knowledge/store'
|
||||||
import type { SortOption, SortOrder } from '../components/shared'
|
import type { SortOption, SortOrder } from '../components/constants'
|
||||||
|
|
||||||
interface KnowledgeBaseWithDocCount extends KnowledgeBaseData {
|
interface KnowledgeBaseWithDocCount extends KnowledgeBaseData {
|
||||||
docCount?: number
|
docCount?: number
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/
|
|||||||
import { ProviderModelsLoader } from '@/app/workspace/[workspaceId]/providers/provider-models-loader'
|
import { ProviderModelsLoader } from '@/app/workspace/[workspaceId]/providers/provider-models-loader'
|
||||||
import { SettingsLoader } from '@/app/workspace/[workspaceId]/providers/settings-loader'
|
import { SettingsLoader } from '@/app/workspace/[workspaceId]/providers/settings-loader'
|
||||||
import { WorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
import { WorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
|
||||||
import { SidebarNew } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar-new'
|
import { Sidebar } from '@/app/workspace/[workspaceId]/w/components/sidebar/sidebar'
|
||||||
|
|
||||||
export default function WorkspaceLayout({ children }: { children: React.ReactNode }) {
|
export default function WorkspaceLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
@@ -14,10 +14,10 @@ export default function WorkspaceLayout({ children }: { children: React.ReactNod
|
|||||||
<ProviderModelsLoader />
|
<ProviderModelsLoader />
|
||||||
<GlobalCommandsProvider>
|
<GlobalCommandsProvider>
|
||||||
<Tooltip.Provider delayDuration={600} skipDelayDuration={0}>
|
<Tooltip.Provider delayDuration={600} skipDelayDuration={0}>
|
||||||
<div className='flex min-h-screen w-full'>
|
<div className='flex h-screen w-full'>
|
||||||
<WorkspacePermissionsProvider>
|
<WorkspacePermissionsProvider>
|
||||||
<div className='shrink-0' suppressHydrationWarning>
|
<div className='shrink-0' suppressHydrationWarning>
|
||||||
<SidebarNew />
|
<Sidebar />
|
||||||
</div>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
</WorkspacePermissionsProvider>
|
</WorkspacePermissionsProvider>
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { LineChart, type LineChartMultiSeries, type LineChartPoint } from './line-chart'
|
||||||
|
export { StatusBar, type StatusBarSegment } from './status-bar'
|
||||||
|
export { type WorkflowExecutionItem, WorkflowsList } from './workflows-list'
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export type { LineChartMultiSeries, LineChartPoint } from './line-chart'
|
||||||
|
export { default, LineChart } from './line-chart'
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { formatDate } from '@/app/workspace/[workspaceId]/logs/utils'
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
|
import { formatDate, formatLatency } from '@/app/workspace/[workspaceId]/logs/utils'
|
||||||
|
|
||||||
export interface LineChartPoint {
|
export interface LineChartPoint {
|
||||||
timestamp: string
|
timestamp: string
|
||||||
@@ -28,6 +29,7 @@ export function LineChart({
|
|||||||
series?: LineChartMultiSeries[]
|
series?: LineChartMultiSeries[]
|
||||||
}) {
|
}) {
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const uniqueId = useRef(`chart-${Math.random().toString(36).substring(2, 9)}`).current
|
||||||
const [containerWidth, setContainerWidth] = useState<number>(420)
|
const [containerWidth, setContainerWidth] = useState<number>(420)
|
||||||
const width = containerWidth
|
const width = containerWidth
|
||||||
const height = 166
|
const height = 166
|
||||||
@@ -54,6 +56,7 @@ export function LineChart({
|
|||||||
const [hoverSeriesId, setHoverSeriesId] = useState<string | null>(null)
|
const [hoverSeriesId, setHoverSeriesId] = useState<string | null>(null)
|
||||||
const [activeSeriesId, setActiveSeriesId] = useState<string | null>(null)
|
const [activeSeriesId, setActiveSeriesId] = useState<string | null>(null)
|
||||||
const [hoverPos, setHoverPos] = useState<{ x: number; y: number } | null>(null)
|
const [hoverPos, setHoverPos] = useState<{ x: number; y: number } | null>(null)
|
||||||
|
const [resolvedColors, setResolvedColors] = useState<Record<string, string>>({})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') return
|
if (typeof window === 'undefined') return
|
||||||
@@ -65,10 +68,40 @@ export function LineChart({
|
|||||||
return () => observer.disconnect()
|
return () => observer.disconnect()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
|
||||||
|
const resolveColor = (c: string): string => {
|
||||||
|
if (!c.startsWith('var(')) return c
|
||||||
|
|
||||||
|
const tempEl = document.createElement('div')
|
||||||
|
tempEl.style.color = c
|
||||||
|
document.body.appendChild(tempEl)
|
||||||
|
const computed = window.getComputedStyle(tempEl).color
|
||||||
|
document.body.removeChild(tempEl)
|
||||||
|
return computed
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorMap: Record<string, string> = { base: resolveColor(color) }
|
||||||
|
const allSeriesToResolve = Array.isArray(series) && series.length > 0 ? series : []
|
||||||
|
|
||||||
|
for (const s of allSeriesToResolve) {
|
||||||
|
const id = s.id || s.label || ''
|
||||||
|
if (id) colorMap[id] = resolveColor(s.color)
|
||||||
|
}
|
||||||
|
|
||||||
|
setResolvedColors(colorMap)
|
||||||
|
}, [color, series])
|
||||||
|
|
||||||
|
const hasExternalWrapper = !label || label === ''
|
||||||
|
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className='flex items-center justify-center rounded-lg border bg-card p-4'
|
className={cn(
|
||||||
|
'flex items-center justify-center',
|
||||||
|
!hasExternalWrapper && 'rounded-lg border bg-card p-4'
|
||||||
|
)}
|
||||||
style={{ width, height }}
|
style={{ width, height }}
|
||||||
>
|
>
|
||||||
<p className='text-muted-foreground text-sm'>No data</p>
|
<p className='text-muted-foreground text-sm'>No data</p>
|
||||||
@@ -90,9 +123,19 @@ export function LineChart({
|
|||||||
const unitSuffixPre = (unit || '').trim().toLowerCase()
|
const unitSuffixPre = (unit || '').trim().toLowerCase()
|
||||||
let maxValue = Math.ceil(paddedMax)
|
let maxValue = Math.ceil(paddedMax)
|
||||||
let minValue = Math.floor(paddedMin)
|
let minValue = Math.floor(paddedMin)
|
||||||
if (unitSuffixPre === 'ms') {
|
if (unitSuffixPre === 'ms' || unitSuffixPre === 'latency') {
|
||||||
maxValue = Math.max(1000, Math.ceil(paddedMax / 1000) * 1000)
|
|
||||||
minValue = 0
|
minValue = 0
|
||||||
|
if (paddedMax < 10) {
|
||||||
|
maxValue = Math.ceil(paddedMax)
|
||||||
|
} else if (paddedMax < 100) {
|
||||||
|
maxValue = Math.ceil(paddedMax / 10) * 10
|
||||||
|
} else if (paddedMax < 1000) {
|
||||||
|
maxValue = Math.ceil(paddedMax / 50) * 50
|
||||||
|
} else if (paddedMax < 10000) {
|
||||||
|
maxValue = Math.ceil(paddedMax / 500) * 500
|
||||||
|
} else {
|
||||||
|
maxValue = Math.ceil(paddedMax / 1000) * 1000
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const valueRange = maxValue - minValue || 1
|
const valueRange = maxValue - minValue || 1
|
||||||
|
|
||||||
@@ -152,7 +195,7 @@ export function LineChart({
|
|||||||
if (!timestamp) return ''
|
if (!timestamp) return ''
|
||||||
try {
|
try {
|
||||||
const f = formatDate(timestamp)
|
const f = formatDate(timestamp)
|
||||||
return `${f.compactDate} · ${f.compactTime}`
|
return `${f.compactDate} ${f.compactTime}`
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const d = new Date(timestamp)
|
const d = new Date(timestamp)
|
||||||
if (Number.isNaN(d.getTime())) return ''
|
if (Number.isNaN(d.getTime())) return ''
|
||||||
@@ -172,51 +215,58 @@ export function LineChart({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className='w-full overflow-hidden rounded-[11px] border bg-card p-4 shadow-sm'
|
className={cn(
|
||||||
|
'w-full overflow-hidden',
|
||||||
|
!hasExternalWrapper && 'rounded-[11px] border bg-card p-4 shadow-sm'
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className='mb-3 flex items-center gap-3'>
|
{!hasExternalWrapper && (
|
||||||
<h4 className='font-medium text-foreground text-sm'>{label}</h4>
|
<div className='mb-3 flex items-center gap-3'>
|
||||||
{allSeries.length > 1 && (
|
<h4 className='font-medium text-foreground text-sm'>{label}</h4>
|
||||||
<div className='flex items-center gap-2'>
|
{allSeries.length > 1 && (
|
||||||
{scaledSeries.slice(1).map((s) => {
|
<div className='flex items-center gap-2'>
|
||||||
const isActive = activeSeriesId ? activeSeriesId === s.id : true
|
{scaledSeries.slice(1).map((s) => {
|
||||||
const isHovered = hoverSeriesId === s.id
|
const isActive = activeSeriesId ? activeSeriesId === s.id : true
|
||||||
const dimmed = activeSeriesId ? !isActive : false
|
const isHovered = hoverSeriesId === s.id
|
||||||
return (
|
const dimmed = activeSeriesId ? !isActive : false
|
||||||
<button
|
return (
|
||||||
key={`legend-${s.id}`}
|
<button
|
||||||
type='button'
|
key={`legend-${s.id}`}
|
||||||
aria-pressed={activeSeriesId === s.id}
|
type='button'
|
||||||
aria-label={`Toggle ${s.label}`}
|
aria-pressed={activeSeriesId === s.id}
|
||||||
className='inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px]'
|
aria-label={`Toggle ${s.label}`}
|
||||||
style={{
|
className='inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px]'
|
||||||
color: s.color,
|
style={{
|
||||||
opacity: dimmed ? 0.4 : isHovered ? 1 : 0.9,
|
color: resolvedColors[s.id || ''] || s.color,
|
||||||
border: '1px solid hsl(var(--border))',
|
opacity: dimmed ? 0.4 : isHovered ? 1 : 0.9,
|
||||||
background: 'transparent',
|
border: '1px solid hsl(var(--border))',
|
||||||
}}
|
background: 'transparent',
|
||||||
onMouseEnter={() => setHoverSeriesId(s.id || null)}
|
}}
|
||||||
onMouseLeave={() => setHoverSeriesId((prev) => (prev === s.id ? null : prev))}
|
onMouseEnter={() => setHoverSeriesId(s.id || null)}
|
||||||
onKeyDown={(e) => {
|
onMouseLeave={() => setHoverSeriesId((prev) => (prev === s.id ? null : prev))}
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
onKeyDown={(e) => {
|
||||||
e.preventDefault()
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
setActiveSeriesId((prev) => (prev === s.id ? null : s.id || null))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClick={() =>
|
||||||
setActiveSeriesId((prev) => (prev === s.id ? null : s.id || null))
|
setActiveSeriesId((prev) => (prev === s.id ? null : s.id || null))
|
||||||
}
|
}
|
||||||
}}
|
>
|
||||||
onClick={() => setActiveSeriesId((prev) => (prev === s.id ? null : s.id || null))}
|
<span
|
||||||
>
|
aria-hidden='true'
|
||||||
<span
|
className='inline-block h-[6px] w-[6px] rounded-[2px]'
|
||||||
aria-hidden='true'
|
style={{ backgroundColor: resolvedColors[s.id || ''] || s.color }}
|
||||||
className='inline-block h-[6px] w-[6px] rounded-full'
|
/>
|
||||||
style={{ backgroundColor: s.color }}
|
<span style={{ color: 'hsl(var(--muted-foreground))' }}>{s.label}</span>
|
||||||
/>
|
</button>
|
||||||
<span style={{ color: 'hsl(var(--muted-foreground))' }}>{s.label}</span>
|
)
|
||||||
</button>
|
})}
|
||||||
)
|
</div>
|
||||||
})}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
<div className='relative' style={{ width, height }}>
|
<div className='relative' style={{ width, height }}>
|
||||||
<svg
|
<svg
|
||||||
width={width}
|
width={width}
|
||||||
@@ -255,15 +305,23 @@ export function LineChart({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id={`area-${label.replace(/\s+/g, '-')}`} x1='0' x2='0' y1='0' y2='1'>
|
<linearGradient id={`area-${uniqueId}`} x1='0' x2='0' y1='0' y2='1'>
|
||||||
<stop offset='0%' stopColor={color} stopOpacity={isDark ? 0.25 : 0.45} />
|
<stop
|
||||||
<stop offset='100%' stopColor={color} stopOpacity={isDark ? 0.03 : 0.08} />
|
offset='0%'
|
||||||
|
stopColor={resolvedColors.base || color}
|
||||||
|
stopOpacity={isDark ? 0.25 : 0.45}
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset='100%'
|
||||||
|
stopColor={resolvedColors.base || color}
|
||||||
|
stopOpacity={isDark ? 0.03 : 0.08}
|
||||||
|
/>
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
<clipPath id={`clip-${label.replace(/\s+/g, '-')}`}>
|
<clipPath id={`clip-${uniqueId}`}>
|
||||||
<rect
|
<rect
|
||||||
x={padding.left}
|
x={padding.left - 3}
|
||||||
y={yMin}
|
y={yMin}
|
||||||
width={Math.max(1, chartWidth)}
|
width={Math.max(1, chartWidth + 6)}
|
||||||
height={chartHeight - (yMin - padding.top) * 2}
|
height={chartHeight - (yMin - padding.top) * 2}
|
||||||
rx='2'
|
rx='2'
|
||||||
/>
|
/>
|
||||||
@@ -281,7 +339,7 @@ export function LineChart({
|
|||||||
|
|
||||||
{[0.25, 0.5, 0.75].map((p) => (
|
{[0.25, 0.5, 0.75].map((p) => (
|
||||||
<line
|
<line
|
||||||
key={`${label}-grid-${p}`}
|
key={`${uniqueId}-grid-${p}`}
|
||||||
x1={padding.left}
|
x1={padding.left}
|
||||||
y1={padding.top + chartHeight * p}
|
y1={padding.top + chartHeight * p}
|
||||||
x2={width - padding.right}
|
x2={width - padding.right}
|
||||||
@@ -292,17 +350,32 @@ export function LineChart({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* axis baseline is drawn last (after line) to visually mask any overshoot */}
|
|
||||||
|
|
||||||
{!activeSeriesId && scaledPoints.length > 1 && (
|
{!activeSeriesId && scaledPoints.length > 1 && (
|
||||||
<path
|
<path
|
||||||
d={`${pathD} L ${scaledPoints[scaledPoints.length - 1].x} ${height - padding.bottom} L ${scaledPoints[0].x} ${height - padding.bottom} Z`}
|
d={`${pathD} L ${scaledPoints[scaledPoints.length - 1].x} ${height - padding.bottom} L ${scaledPoints[0].x} ${height - padding.bottom} Z`}
|
||||||
fill={`url(#area-${label.replace(/\s+/g, '-')})`}
|
fill={`url(#area-${uniqueId})`}
|
||||||
stroke='none'
|
stroke='none'
|
||||||
clipPath={`url(#clip-${label.replace(/\s+/g, '-')})`}
|
clipPath={`url(#clip-${uniqueId})`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!activeSeriesId &&
|
||||||
|
scaledPoints.length === 1 &&
|
||||||
|
(() => {
|
||||||
|
const strokeWidth = isDark ? 1.7 : 2.0
|
||||||
|
const capExtension = strokeWidth / 2
|
||||||
|
return (
|
||||||
|
<rect
|
||||||
|
x={padding.left - capExtension}
|
||||||
|
y={scaledPoints[0].y}
|
||||||
|
width={Math.max(1, chartWidth + capExtension * 2)}
|
||||||
|
height={height - padding.bottom - scaledPoints[0].y}
|
||||||
|
fill={`url(#area-${uniqueId})`}
|
||||||
|
clipPath={`url(#clip-${uniqueId})`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
{orderedSeries.map((s, idx) => {
|
{orderedSeries.map((s, idx) => {
|
||||||
const isActive = activeSeriesId ? activeSeriesId === s.id : true
|
const isActive = activeSeriesId ? activeSeriesId === s.id : true
|
||||||
const isHovered = hoverSeriesId ? hoverSeriesId === s.id : false
|
const isHovered = hoverSeriesId ? hoverSeriesId === s.id : false
|
||||||
@@ -321,14 +394,20 @@ export function LineChart({
|
|||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
if (s.pts.length <= 1) {
|
if (s.pts.length <= 1) {
|
||||||
|
const y = s.pts[0]?.y
|
||||||
|
if (y === undefined) return null
|
||||||
return (
|
return (
|
||||||
<circle
|
<line
|
||||||
key={`pt-${idx}`}
|
key={`pt-${idx}`}
|
||||||
cx={s.pts[0]?.x}
|
x1={padding.left}
|
||||||
cy={s.pts[0]?.y}
|
y1={y}
|
||||||
r='3'
|
x2={width - padding.right}
|
||||||
fill={s.color}
|
y2={y}
|
||||||
|
stroke={resolvedColors[s.id || ''] || s.color}
|
||||||
|
strokeWidth={sw}
|
||||||
|
strokeLinecap='round'
|
||||||
opacity={strokeOpacity}
|
opacity={strokeOpacity}
|
||||||
|
strokeDasharray={s.dashed ? '5 4' : undefined}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -356,11 +435,11 @@ export function LineChart({
|
|||||||
key={`series-${idx}`}
|
key={`series-${idx}`}
|
||||||
d={p}
|
d={p}
|
||||||
fill='none'
|
fill='none'
|
||||||
stroke={s.color}
|
stroke={resolvedColors[s.id || ''] || s.color}
|
||||||
strokeWidth={sw}
|
strokeWidth={sw}
|
||||||
strokeLinecap='round'
|
strokeLinecap='round'
|
||||||
clipPath={`url(#clip-${label.replace(/\s+/g, '-')})`}
|
clipPath={`url(#clip-${uniqueId})`}
|
||||||
style={{ cursor: 'pointer', mixBlendMode: isDark ? 'screen' : 'normal' }}
|
style={{ mixBlendMode: isDark ? 'screen' : 'normal' }}
|
||||||
strokeDasharray={s.dashed ? '5 4' : undefined}
|
strokeDasharray={s.dashed ? '5 4' : undefined}
|
||||||
opacity={strokeOpacity}
|
opacity={strokeOpacity}
|
||||||
onClick={() => setActiveSeriesId((prev) => (prev === s.id ? null : s.id || null))}
|
onClick={() => setActiveSeriesId((prev) => (prev === s.id ? null : s.id || null))}
|
||||||
@@ -377,13 +456,13 @@ export function LineChart({
|
|||||||
const active = guideSeries
|
const active = guideSeries
|
||||||
const pt = active.pts[hoverIndex] || scaledPoints[hoverIndex]
|
const pt = active.pts[hoverIndex] || scaledPoints[hoverIndex]
|
||||||
return (
|
return (
|
||||||
<g pointerEvents='none' clipPath={`url(#clip-${label.replace(/\s+/g, '-')})`}>
|
<g pointerEvents='none' clipPath={`url(#clip-${uniqueId})`}>
|
||||||
<line
|
<line
|
||||||
x1={pt.x}
|
x1={pt.x}
|
||||||
y1={padding.top}
|
y1={padding.top}
|
||||||
x2={pt.x}
|
x2={pt.x}
|
||||||
y2={height - padding.bottom}
|
y2={height - padding.bottom}
|
||||||
stroke={active.color}
|
stroke={resolvedColors[active.id || ''] || active.color}
|
||||||
strokeOpacity='0.35'
|
strokeOpacity='0.35'
|
||||||
strokeDasharray='3 3'
|
strokeDasharray='3 3'
|
||||||
/>
|
/>
|
||||||
@@ -392,7 +471,14 @@ export function LineChart({
|
|||||||
const s = getSeriesById(activeSeriesId)
|
const s = getSeriesById(activeSeriesId)
|
||||||
const spt = s?.pts?.[hoverIndex]
|
const spt = s?.pts?.[hoverIndex]
|
||||||
if (!s || !spt) return null
|
if (!s || !spt) return null
|
||||||
return <circle cx={spt.x} cy={spt.y} r='3' fill={s.color} />
|
return (
|
||||||
|
<circle
|
||||||
|
cx={spt.x}
|
||||||
|
cy={spt.y}
|
||||||
|
r='3'
|
||||||
|
fill={resolvedColors[s.id || ''] || s.color}
|
||||||
|
/>
|
||||||
|
)
|
||||||
})()}
|
})()}
|
||||||
</g>
|
</g>
|
||||||
)
|
)
|
||||||
@@ -439,12 +525,12 @@ export function LineChart({
|
|||||||
const labelStr = Number.isNaN(ts.getTime()) ? '' : formatTick(ts)
|
const labelStr = Number.isNaN(ts.getTime()) ? '' : formatTick(ts)
|
||||||
return (
|
return (
|
||||||
<text
|
<text
|
||||||
key={`${label}-x-axis-${i}`}
|
key={`${uniqueId}-x-axis-${i}`}
|
||||||
x={x}
|
x={x}
|
||||||
y={height - padding.bottom + 14}
|
y={height - padding.bottom + 14}
|
||||||
fontSize='9'
|
fontSize='9'
|
||||||
textAnchor='middle'
|
textAnchor='middle'
|
||||||
fill='hsl(var(--muted-foreground))'
|
fill='var(--text-tertiary)'
|
||||||
>
|
>
|
||||||
{labelStr}
|
{labelStr}
|
||||||
</text>
|
</text>
|
||||||
@@ -455,13 +541,19 @@ export function LineChart({
|
|||||||
{(() => {
|
{(() => {
|
||||||
const unitSuffix = (unit || '').trim()
|
const unitSuffix = (unit || '').trim()
|
||||||
const showInTicks = unitSuffix === '%'
|
const showInTicks = unitSuffix === '%'
|
||||||
const fmtCompact = (v: number) =>
|
const isLatency = unitSuffix.toLowerCase() === 'latency'
|
||||||
new Intl.NumberFormat('en-US', {
|
const fmtCompact = (v: number) => {
|
||||||
|
if (isLatency) {
|
||||||
|
if (v === 0) return '0'
|
||||||
|
return formatLatency(v)
|
||||||
|
}
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
notation: 'compact',
|
notation: 'compact',
|
||||||
maximumFractionDigits: 1,
|
maximumFractionDigits: 1,
|
||||||
})
|
})
|
||||||
.format(v)
|
.format(v)
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<text
|
<text
|
||||||
@@ -469,20 +561,20 @@ export function LineChart({
|
|||||||
y={padding.top}
|
y={padding.top}
|
||||||
textAnchor='end'
|
textAnchor='end'
|
||||||
fontSize='9'
|
fontSize='9'
|
||||||
fill='hsl(var(--muted-foreground))'
|
fill='var(--text-tertiary)'
|
||||||
>
|
>
|
||||||
{fmtCompact(maxValue)}
|
{fmtCompact(maxValue)}
|
||||||
{showInTicks ? unit : ''}
|
{showInTicks && !isLatency ? unit : ''}
|
||||||
</text>
|
</text>
|
||||||
<text
|
<text
|
||||||
x={padding.left - 8}
|
x={padding.left - 8}
|
||||||
y={height - padding.bottom}
|
y={height - padding.bottom}
|
||||||
textAnchor='end'
|
textAnchor='end'
|
||||||
fontSize='9'
|
fontSize='9'
|
||||||
fill='hsl(var(--muted-foreground))'
|
fill='var(--text-tertiary)'
|
||||||
>
|
>
|
||||||
{fmtCompact(minValue)}
|
{fmtCompact(minValue)}
|
||||||
{showInTicks ? unit : ''}
|
{showInTicks && !isLatency ? unit : ''}
|
||||||
</text>
|
</text>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@@ -498,8 +590,6 @@ export function LineChart({
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
{/* No end labels to keep the chart clean and avoid edge overlap */}
|
|
||||||
|
|
||||||
{hoverIndex !== null &&
|
{hoverIndex !== null &&
|
||||||
scaledPoints[hoverIndex] &&
|
scaledPoints[hoverIndex] &&
|
||||||
(() => {
|
(() => {
|
||||||
@@ -516,6 +606,7 @@ export function LineChart({
|
|||||||
if (typeof v !== 'number' || !Number.isFinite(v)) return '—'
|
if (typeof v !== 'number' || !Number.isFinite(v)) return '—'
|
||||||
const u = unit || ''
|
const u = unit || ''
|
||||||
if (u.includes('%')) return `${v.toFixed(1)}%`
|
if (u.includes('%')) return `${v.toFixed(1)}%`
|
||||||
|
if (u.toLowerCase() === 'latency') return formatLatency(v)
|
||||||
if (u.toLowerCase().includes('ms')) return `${Math.round(v)}ms`
|
if (u.toLowerCase().includes('ms')) return `${Math.round(v)}ms`
|
||||||
if (u.toLowerCase().includes('exec')) return `${Math.round(v)}`
|
if (u.toLowerCase().includes('exec')) return `${Math.round(v)}`
|
||||||
return `${Math.round(v)}${u}`
|
return `${Math.round(v)}${u}`
|
||||||
@@ -546,25 +637,30 @@ export function LineChart({
|
|||||||
const top = Math.min(Math.max(anchorY - 26, padding.top), height - padding.bottom - 18)
|
const top = Math.min(Math.max(anchorY - 26, padding.top), height - padding.bottom - 18)
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className='pointer-events-none absolute rounded-md bg-background/80 px-2 py-1 font-medium text-[11px] shadow-sm ring-1 ring-border backdrop-blur'
|
className='pointer-events-none absolute rounded-[8px] border border-[var(--border-strong)] bg-[var(--surface-1)] px-[8px] py-[6px] font-medium text-[11px] shadow-lg'
|
||||||
style={{ left, top }}
|
style={{ left, top }}
|
||||||
>
|
>
|
||||||
{currentHoverDate && (
|
{currentHoverDate && (
|
||||||
<div className='mb-1 text-[10px] text-muted-foreground'>{currentHoverDate}</div>
|
<div className='mb-1 text-[10px] text-[var(--text-tertiary)]'>
|
||||||
|
{currentHoverDate}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{toDisplay.map((s) => {
|
{toDisplay.map((s) => {
|
||||||
const seriesIndex = allSeries.findIndex((x) => x.id === s.id)
|
const seriesIndex = allSeries.findIndex((x) => x.id === s.id)
|
||||||
const val = allSeries[seriesIndex]?.data?.[hoverIndex]?.value
|
const val = allSeries[seriesIndex]?.data?.[hoverIndex]?.value
|
||||||
|
const seriesLabel = s.label || s.id
|
||||||
|
const showLabel =
|
||||||
|
seriesLabel && seriesLabel !== 'base' && seriesLabel.trim() !== ''
|
||||||
return (
|
return (
|
||||||
<div key={`tt-${s.id}`} className='flex items-center gap-1'>
|
<div key={`tt-${s.id}`} className='flex items-center gap-[8px]'>
|
||||||
<span
|
<span
|
||||||
className='inline-block h-[6px] w-[6px] rounded-full'
|
className='inline-block h-[6px] w-[6px] rounded-[2px]'
|
||||||
style={{ backgroundColor: s.color }}
|
style={{ backgroundColor: resolvedColors[s.id || ''] || s.color }}
|
||||||
/>
|
/>
|
||||||
<span style={{ color: 'hsl(var(--muted-foreground))' }}>
|
{showLabel && (
|
||||||
{s.label || s.id}
|
<span className='text-[var(--text-secondary)]'>{seriesLabel}</span>
|
||||||
</span>
|
)}
|
||||||
<span>{fmt(val)}</span>
|
<span className='text-[var(--text-primary)]'>{fmt(val)}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export type { StatusBarSegment } from './status-bar'
|
||||||
|
export { default, StatusBar } from './status-bar'
|
||||||
@@ -57,21 +57,28 @@ export function StatusBar({
|
|||||||
: false
|
: false
|
||||||
|
|
||||||
let color: string
|
let color: string
|
||||||
|
let hoverBrightness: string
|
||||||
if (!segment.hasExecutions) {
|
if (!segment.hasExecutions) {
|
||||||
color = 'bg-gray-300/60 dark:bg-gray-500/40'
|
color = 'bg-gray-300/60 dark:bg-gray-500/40'
|
||||||
|
hoverBrightness = 'hover:brightness-200'
|
||||||
} else if (segment.successRate === 100) {
|
} else if (segment.successRate === 100) {
|
||||||
color = 'bg-emerald-400/90'
|
color = 'bg-emerald-400/90'
|
||||||
|
hoverBrightness = 'hover:brightness-110'
|
||||||
} else if (segment.successRate >= 95) {
|
} else if (segment.successRate >= 95) {
|
||||||
color = 'bg-amber-400/90'
|
color = 'bg-amber-400/90'
|
||||||
|
hoverBrightness = 'hover:brightness-110'
|
||||||
} else {
|
} else {
|
||||||
color = 'bg-red-400/90'
|
color = 'bg-red-400/90'
|
||||||
|
hoverBrightness = 'hover:brightness-110'
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className={`h-6 flex-1 rounded-[3px] ${color} cursor-pointer transition-[opacity,transform] hover:opacity-90 ${
|
className={`h-6 flex-1 rounded-[3px] ${color} ${hoverBrightness} cursor-pointer transition-all ${
|
||||||
isSelected ? 'relative z-10 ring-2 ring-primary ring-offset-1' : 'relative z-0'
|
isSelected
|
||||||
|
? 'relative z-10 scale-105 shadow-sm ring-1 ring-[var(--text-secondary)]'
|
||||||
|
: 'relative z-0'
|
||||||
}`}
|
}`}
|
||||||
aria-label={`Segment ${i + 1}`}
|
aria-label={`Segment ${i + 1}`}
|
||||||
onMouseEnter={() => setHoverIndex(i)}
|
onMouseEnter={() => setHoverIndex(i)}
|
||||||
@@ -90,7 +97,7 @@ export function StatusBar({
|
|||||||
|
|
||||||
{hoverIndex !== null && segments[hoverIndex] && (
|
{hoverIndex !== null && segments[hoverIndex] && (
|
||||||
<div
|
<div
|
||||||
className={`-translate-x-1/2 pointer-events-none absolute z-20 w-max whitespace-nowrap rounded-md bg-background/90 px-2 py-1 text-center text-[11px] shadow-sm ring-1 ring-border backdrop-blur ${
|
className={`-translate-x-1/2 pointer-events-none absolute z-20 w-max whitespace-nowrap rounded-[8px] border border-[var(--border-strong)] bg-[var(--surface-1)] px-[8px] py-[6px] text-center text-[11px] shadow-lg ${
|
||||||
preferBelow ? '' : '-translate-y-full'
|
preferBelow ? '' : '-translate-y-full'
|
||||||
}`}
|
}`}
|
||||||
style={{
|
style={{
|
||||||
@@ -101,14 +108,18 @@ export function StatusBar({
|
|||||||
>
|
>
|
||||||
{segments[hoverIndex].hasExecutions ? (
|
{segments[hoverIndex].hasExecutions ? (
|
||||||
<div>
|
<div>
|
||||||
<div className='font-semibold'>{labels[hoverIndex].successLabel}</div>
|
<div className='font-semibold text-[var(--text-primary)]'>
|
||||||
<div className='text-muted-foreground'>{labels[hoverIndex].countsLabel}</div>
|
{labels[hoverIndex].successLabel}
|
||||||
|
</div>
|
||||||
|
<div className='text-[var(--text-secondary)]'>{labels[hoverIndex].countsLabel}</div>
|
||||||
{labels[hoverIndex].rangeLabel && (
|
{labels[hoverIndex].rangeLabel && (
|
||||||
<div className='mt-0.5 text-muted-foreground'>{labels[hoverIndex].rangeLabel}</div>
|
<div className='mt-0.5 text-[var(--text-tertiary)]'>
|
||||||
|
{labels[hoverIndex].rangeLabel}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className='text-muted-foreground'>{labels[hoverIndex].rangeLabel}</div>
|
<div className='text-[var(--text-secondary)]'>{labels[hoverIndex].rangeLabel}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export type { WorkflowExecutionItem } from './workflows-list'
|
||||||
|
export { default, WorkflowsList } from './workflows-list'
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { memo } from 'react'
|
||||||
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
|
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||||
|
import { StatusBar, type StatusBarSegment } from '..'
|
||||||
|
|
||||||
|
export interface WorkflowExecutionItem {
|
||||||
|
workflowId: string
|
||||||
|
workflowName: string
|
||||||
|
segments: StatusBarSegment[]
|
||||||
|
overallSuccessRate: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkflowsList({
|
||||||
|
executions,
|
||||||
|
filteredExecutions,
|
||||||
|
expandedWorkflowId,
|
||||||
|
onToggleWorkflow,
|
||||||
|
selectedSegments,
|
||||||
|
onSegmentClick,
|
||||||
|
searchQuery,
|
||||||
|
segmentDurationMs,
|
||||||
|
}: {
|
||||||
|
executions: WorkflowExecutionItem[]
|
||||||
|
filteredExecutions: WorkflowExecutionItem[]
|
||||||
|
expandedWorkflowId: string | null
|
||||||
|
onToggleWorkflow: (workflowId: string) => void
|
||||||
|
selectedSegments: Record<string, number[]>
|
||||||
|
onSegmentClick: (
|
||||||
|
workflowId: string,
|
||||||
|
segmentIndex: number,
|
||||||
|
timestamp: string,
|
||||||
|
mode: 'single' | 'toggle' | 'range'
|
||||||
|
) => void
|
||||||
|
searchQuery: string
|
||||||
|
segmentDurationMs: number
|
||||||
|
}) {
|
||||||
|
const { workflows } = useWorkflowRegistry()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex h-full flex-col overflow-hidden rounded-[6px] bg-[var(--surface-1)]'>
|
||||||
|
{/* Table header */}
|
||||||
|
<div className='flex-shrink-0 rounded-t-[6px] bg-[var(--surface-3)] px-[24px] py-[10px]'>
|
||||||
|
<div className='flex items-center gap-[16px]'>
|
||||||
|
<span className='w-[160px] flex-shrink-0 font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||||
|
Workflow
|
||||||
|
</span>
|
||||||
|
<span className='flex-1 font-medium text-[12px] text-[var(--text-tertiary)]'>Logs</span>
|
||||||
|
<span className='w-[100px] flex-shrink-0 pl-[16px] font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||||
|
Success Rate
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table body - scrollable */}
|
||||||
|
<div className='min-h-0 flex-1 overflow-y-auto overflow-x-hidden'>
|
||||||
|
{filteredExecutions.length === 0 ? (
|
||||||
|
<div className='flex items-center justify-center py-[32px]'>
|
||||||
|
<span className='text-[13px] text-[var(--text-secondary)]'>
|
||||||
|
{searchQuery ? `No workflows found matching "${searchQuery}"` : 'No workflows found'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{filteredExecutions.map((workflow, idx) => {
|
||||||
|
const isSelected = expandedWorkflowId === workflow.workflowId
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={workflow.workflowId}
|
||||||
|
className={cn(
|
||||||
|
'flex h-[44px] cursor-pointer items-center gap-[16px] px-[24px] hover:bg-[var(--c-2A2A2A)]',
|
||||||
|
isSelected && 'bg-[var(--c-2A2A2A)]'
|
||||||
|
)}
|
||||||
|
onClick={() => onToggleWorkflow(workflow.workflowId)}
|
||||||
|
>
|
||||||
|
{/* Workflow name with color */}
|
||||||
|
<div className='flex w-[160px] flex-shrink-0 items-center gap-[8px] pr-[8px]'>
|
||||||
|
<div
|
||||||
|
className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px]'
|
||||||
|
style={{
|
||||||
|
backgroundColor: workflows[workflow.workflowId]?.color || '#64748b',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className='min-w-0 truncate font-medium text-[12px] text-[var(--text-primary)]'>
|
||||||
|
{workflow.workflowName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status bar - takes most of the space */}
|
||||||
|
<div className='flex-1'>
|
||||||
|
<StatusBar
|
||||||
|
segments={workflow.segments}
|
||||||
|
selectedSegmentIndices={selectedSegments[workflow.workflowId] || null}
|
||||||
|
onSegmentClick={onSegmentClick as any}
|
||||||
|
workflowId={workflow.workflowId}
|
||||||
|
segmentDurationMs={segmentDurationMs}
|
||||||
|
preferBelow={idx < 2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Success rate */}
|
||||||
|
<span className='w-[100px] flex-shrink-0 pl-[16px] font-medium text-[12px] text-[var(--text-primary)]'>
|
||||||
|
{workflow.overallSuccessRate.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(WorkflowsList)
|
||||||
@@ -0,0 +1,887 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { Loader2 } from 'lucide-react'
|
||||||
|
import { useParams } from 'next/navigation'
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import {
|
||||||
|
formatLatency,
|
||||||
|
mapToExecutionLog,
|
||||||
|
mapToExecutionLogAlt,
|
||||||
|
} from '@/app/workspace/[workspaceId]/logs/utils'
|
||||||
|
import {
|
||||||
|
useExecutionsMetrics,
|
||||||
|
useGlobalDashboardLogs,
|
||||||
|
useWorkflowDashboardLogs,
|
||||||
|
} from '@/hooks/queries/logs'
|
||||||
|
import { useFilterStore } from '@/stores/logs/filters/store'
|
||||||
|
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
||||||
|
import { LineChart, WorkflowsList } from './components'
|
||||||
|
|
||||||
|
type TimeFilter = '30m' | '1h' | '6h' | '12h' | '24h' | '3d' | '7d' | '14d' | '30d'
|
||||||
|
|
||||||
|
interface WorkflowExecution {
|
||||||
|
workflowId: string
|
||||||
|
workflowName: string
|
||||||
|
segments: {
|
||||||
|
successRate: number
|
||||||
|
timestamp: string
|
||||||
|
hasExecutions: boolean
|
||||||
|
totalExecutions: number
|
||||||
|
successfulExecutions: number
|
||||||
|
avgDurationMs?: number
|
||||||
|
p50Ms?: number
|
||||||
|
p90Ms?: number
|
||||||
|
p99Ms?: number
|
||||||
|
}[]
|
||||||
|
overallSuccessRate: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SEGMENTS = 72
|
||||||
|
const MIN_SEGMENT_PX = 10
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skeleton loader for a single graph card
|
||||||
|
*/
|
||||||
|
function GraphCardSkeleton({ title }: { title: string }) {
|
||||||
|
return (
|
||||||
|
<div className='flex flex-col overflow-hidden rounded-[6px] bg-[var(--surface-elevated)]'>
|
||||||
|
<div className='flex min-w-0 items-center justify-between gap-[8px] bg-[var(--surface-3)] px-[16px] py-[9px]'>
|
||||||
|
<span className='min-w-0 truncate font-medium text-[var(--text-primary)] text-sm'>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
<Skeleton className='h-[20px] w-[40px]' />
|
||||||
|
</div>
|
||||||
|
<div className='flex-1 overflow-y-auto rounded-t-[6px] bg-[var(--surface-1)] px-[14px] py-[10px]'>
|
||||||
|
<div className='flex h-[166px] flex-col justify-end gap-[4px]'>
|
||||||
|
{/* Skeleton bars simulating chart */}
|
||||||
|
<div className='flex items-end gap-[2px]'>
|
||||||
|
{Array.from({ length: 24 }).map((_, i) => (
|
||||||
|
<Skeleton
|
||||||
|
key={i}
|
||||||
|
className='flex-1'
|
||||||
|
style={{
|
||||||
|
height: `${Math.random() * 80 + 20}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skeleton loader for a workflow row in the workflows list
|
||||||
|
*/
|
||||||
|
function WorkflowRowSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className='flex h-[44px] items-center gap-[16px] px-[24px]'>
|
||||||
|
{/* Workflow name with color */}
|
||||||
|
<div className='flex w-[160px] flex-shrink-0 items-center gap-[8px] pr-[8px]'>
|
||||||
|
<Skeleton className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px]' />
|
||||||
|
<Skeleton className='h-[16px] flex-1' />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status bar - takes most of the space */}
|
||||||
|
<div className='flex-1'>
|
||||||
|
<Skeleton className='h-[24px] w-full rounded-[4px]' />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Success rate */}
|
||||||
|
<div className='w-[100px] flex-shrink-0 pl-[16px]'>
|
||||||
|
<Skeleton className='h-[16px] w-[50px]' />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skeleton loader for the workflows list table
|
||||||
|
*/
|
||||||
|
function WorkflowsListSkeleton({ rowCount = 5 }: { rowCount?: number }) {
|
||||||
|
return (
|
||||||
|
<div className='flex h-full flex-col overflow-hidden rounded-[6px] bg-[var(--surface-1)]'>
|
||||||
|
{/* Table header */}
|
||||||
|
<div className='flex-shrink-0 rounded-t-[6px] bg-[var(--surface-3)] px-[24px] py-[10px]'>
|
||||||
|
<div className='flex items-center gap-[16px]'>
|
||||||
|
<span className='w-[160px] flex-shrink-0 font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||||
|
Workflow
|
||||||
|
</span>
|
||||||
|
<span className='flex-1 font-medium text-[12px] text-[var(--text-tertiary)]'>Logs</span>
|
||||||
|
<span className='w-[100px] flex-shrink-0 pl-[16px] font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||||
|
Success Rate
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table body - scrollable */}
|
||||||
|
<div className='min-h-0 flex-1 overflow-y-auto overflow-x-hidden'>
|
||||||
|
{Array.from({ length: rowCount }).map((_, i) => (
|
||||||
|
<WorkflowRowSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete skeleton loader for the entire dashboard
|
||||||
|
*/
|
||||||
|
function DashboardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className='mt-[24px] flex min-h-0 flex-1 flex-col'>
|
||||||
|
{/* Graphs Section */}
|
||||||
|
<div className='mb-[16px] flex-shrink-0'>
|
||||||
|
<div className='grid grid-cols-1 gap-[16px] md:grid-cols-3'>
|
||||||
|
<GraphCardSkeleton title='Runs' />
|
||||||
|
<GraphCardSkeleton title='Errors' />
|
||||||
|
<GraphCardSkeleton title='Latency' />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Workflows Table - takes remaining space */}
|
||||||
|
<div className='min-h-0 flex-1 overflow-hidden'>
|
||||||
|
<WorkflowsListSkeleton rowCount={14} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DashboardProps {
|
||||||
|
isLive?: boolean
|
||||||
|
refreshTrigger?: number
|
||||||
|
onCustomTimeRangeChange?: (isCustom: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Dashboard({
|
||||||
|
isLive = false,
|
||||||
|
refreshTrigger = 0,
|
||||||
|
onCustomTimeRangeChange,
|
||||||
|
}: DashboardProps) {
|
||||||
|
const params = useParams()
|
||||||
|
const workspaceId = params.workspaceId as string
|
||||||
|
|
||||||
|
const getTimeFilterFromRange = (range: string): TimeFilter => {
|
||||||
|
switch (range) {
|
||||||
|
case 'Past 30 minutes':
|
||||||
|
return '30m'
|
||||||
|
case 'Past hour':
|
||||||
|
return '1h'
|
||||||
|
case 'Past 6 hours':
|
||||||
|
return '6h'
|
||||||
|
case 'Past 12 hours':
|
||||||
|
return '12h'
|
||||||
|
case 'Past 24 hours':
|
||||||
|
return '24h'
|
||||||
|
case 'Past 3 days':
|
||||||
|
return '3d'
|
||||||
|
case 'Past 7 days':
|
||||||
|
return '7d'
|
||||||
|
case 'Past 14 days':
|
||||||
|
return '14d'
|
||||||
|
case 'Past 30 days':
|
||||||
|
return '30d'
|
||||||
|
default:
|
||||||
|
return '30d'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [endTime, setEndTime] = useState<Date>(new Date())
|
||||||
|
const [expandedWorkflowId, setExpandedWorkflowId] = useState<string | null>(null)
|
||||||
|
const [selectedSegments, setSelectedSegments] = useState<Record<string, number[]>>({})
|
||||||
|
const [lastAnchorIndices, setLastAnchorIndices] = useState<Record<string, number>>({})
|
||||||
|
const [segmentCount, setSegmentCount] = useState<number>(DEFAULT_SEGMENTS)
|
||||||
|
const barsAreaRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
const {
|
||||||
|
workflowIds,
|
||||||
|
folderIds,
|
||||||
|
triggers,
|
||||||
|
timeRange: sidebarTimeRange,
|
||||||
|
level,
|
||||||
|
searchQuery,
|
||||||
|
} = useFilterStore()
|
||||||
|
|
||||||
|
const { workflows } = useWorkflowRegistry()
|
||||||
|
|
||||||
|
const timeFilter = getTimeFilterFromRange(sidebarTimeRange)
|
||||||
|
|
||||||
|
const getStartTime = useCallback(() => {
|
||||||
|
const start = new Date(endTime)
|
||||||
|
|
||||||
|
switch (timeFilter) {
|
||||||
|
case '30m':
|
||||||
|
start.setMinutes(endTime.getMinutes() - 30)
|
||||||
|
break
|
||||||
|
case '1h':
|
||||||
|
start.setHours(endTime.getHours() - 1)
|
||||||
|
break
|
||||||
|
case '6h':
|
||||||
|
start.setHours(endTime.getHours() - 6)
|
||||||
|
break
|
||||||
|
case '12h':
|
||||||
|
start.setHours(endTime.getHours() - 12)
|
||||||
|
break
|
||||||
|
case '24h':
|
||||||
|
start.setHours(endTime.getHours() - 24)
|
||||||
|
break
|
||||||
|
case '3d':
|
||||||
|
start.setDate(endTime.getDate() - 3)
|
||||||
|
break
|
||||||
|
case '7d':
|
||||||
|
start.setDate(endTime.getDate() - 7)
|
||||||
|
break
|
||||||
|
case '14d':
|
||||||
|
start.setDate(endTime.getDate() - 14)
|
||||||
|
break
|
||||||
|
case '30d':
|
||||||
|
start.setDate(endTime.getDate() - 30)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
start.setHours(endTime.getHours() - 24)
|
||||||
|
}
|
||||||
|
|
||||||
|
return start
|
||||||
|
}, [endTime, timeFilter])
|
||||||
|
|
||||||
|
const metricsFilters = useMemo(
|
||||||
|
() => ({
|
||||||
|
workspaceId,
|
||||||
|
segments: segmentCount || DEFAULT_SEGMENTS,
|
||||||
|
startTime: getStartTime().toISOString(),
|
||||||
|
endTime: endTime.toISOString(),
|
||||||
|
workflowIds: workflowIds.length > 0 ? workflowIds : undefined,
|
||||||
|
folderIds: folderIds.length > 0 ? folderIds : undefined,
|
||||||
|
triggers: triggers.length > 0 ? triggers : undefined,
|
||||||
|
level: level !== 'all' ? level : undefined,
|
||||||
|
}),
|
||||||
|
[workspaceId, segmentCount, getStartTime, endTime, workflowIds, folderIds, triggers, level]
|
||||||
|
)
|
||||||
|
|
||||||
|
const logsFilters = useMemo(
|
||||||
|
() => ({
|
||||||
|
workspaceId,
|
||||||
|
startDate: getStartTime().toISOString(),
|
||||||
|
endDate: endTime.toISOString(),
|
||||||
|
workflowIds: workflowIds.length > 0 ? workflowIds : undefined,
|
||||||
|
folderIds: folderIds.length > 0 ? folderIds : undefined,
|
||||||
|
triggers: triggers.length > 0 ? triggers : undefined,
|
||||||
|
level: level !== 'all' ? level : undefined,
|
||||||
|
searchQuery: searchQuery.trim() || undefined,
|
||||||
|
limit: 50,
|
||||||
|
}),
|
||||||
|
[workspaceId, getStartTime, endTime, workflowIds, folderIds, triggers, level, searchQuery]
|
||||||
|
)
|
||||||
|
|
||||||
|
const metricsQuery = useExecutionsMetrics(metricsFilters, {
|
||||||
|
enabled: Boolean(workspaceId),
|
||||||
|
})
|
||||||
|
|
||||||
|
const globalLogsQuery = useGlobalDashboardLogs(logsFilters, {
|
||||||
|
enabled: Boolean(workspaceId),
|
||||||
|
})
|
||||||
|
|
||||||
|
const workflowLogsQuery = useWorkflowDashboardLogs(expandedWorkflowId ?? undefined, logsFilters, {
|
||||||
|
enabled: Boolean(workspaceId) && Boolean(expandedWorkflowId),
|
||||||
|
})
|
||||||
|
|
||||||
|
const executions = metricsQuery.data?.workflows ?? []
|
||||||
|
const aggregateSegments = metricsQuery.data?.aggregateSegments ?? []
|
||||||
|
const loading = metricsQuery.isLoading
|
||||||
|
const error = metricsQuery.error?.message ?? null
|
||||||
|
|
||||||
|
// Check if any filters are actually applied
|
||||||
|
const hasActiveFilters = useMemo(
|
||||||
|
() =>
|
||||||
|
level !== 'all' ||
|
||||||
|
workflowIds.length > 0 ||
|
||||||
|
folderIds.length > 0 ||
|
||||||
|
triggers.length > 0 ||
|
||||||
|
searchQuery.trim() !== '',
|
||||||
|
[level, workflowIds, folderIds, triggers, searchQuery]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Filter workflows based on search query and whether they have any executions matching the filters
|
||||||
|
const filteredExecutions = useMemo(() => {
|
||||||
|
let filtered = executions
|
||||||
|
|
||||||
|
// Only filter out workflows with no executions if filters are active
|
||||||
|
if (hasActiveFilters) {
|
||||||
|
filtered = filtered.filter((workflow) => {
|
||||||
|
const hasExecutions = workflow.segments.some((seg) => seg.hasExecutions === true)
|
||||||
|
return hasExecutions
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply search query filter
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
const query = searchQuery.toLowerCase().trim()
|
||||||
|
filtered = filtered.filter((workflow) => workflow.workflowName.toLowerCase().includes(query))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by creation date (newest first) to match sidebar ordering
|
||||||
|
filtered = filtered.sort((a, b) => {
|
||||||
|
const workflowA = workflows[a.workflowId]
|
||||||
|
const workflowB = workflows[b.workflowId]
|
||||||
|
if (!workflowA || !workflowB) return 0
|
||||||
|
return workflowB.createdAt.getTime() - workflowA.createdAt.getTime()
|
||||||
|
})
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
}, [executions, searchQuery, hasActiveFilters, workflows])
|
||||||
|
|
||||||
|
const globalLogs = useMemo(() => {
|
||||||
|
if (!globalLogsQuery.data?.pages) return []
|
||||||
|
return globalLogsQuery.data.pages.flatMap((page) => page.logs).map(mapToExecutionLog)
|
||||||
|
}, [globalLogsQuery.data?.pages])
|
||||||
|
|
||||||
|
const workflowLogs = useMemo(() => {
|
||||||
|
if (!workflowLogsQuery.data?.pages) return []
|
||||||
|
return workflowLogsQuery.data.pages.flatMap((page) => page.logs).map(mapToExecutionLogAlt)
|
||||||
|
}, [workflowLogsQuery.data?.pages])
|
||||||
|
|
||||||
|
const globalDetails = useMemo(() => {
|
||||||
|
if (!aggregateSegments.length) return null
|
||||||
|
|
||||||
|
const hasSelection = Object.keys(selectedSegments).length > 0
|
||||||
|
const hasWorkflowFilter = expandedWorkflowId && expandedWorkflowId !== '__multi__'
|
||||||
|
|
||||||
|
// Stack filters: workflow filter + segment selection
|
||||||
|
const segmentsToUse = hasSelection
|
||||||
|
? (() => {
|
||||||
|
// Get all selected segment indices across all workflows
|
||||||
|
const allSelectedIndices = new Set<number>()
|
||||||
|
Object.values(selectedSegments).forEach((indices) => {
|
||||||
|
indices.forEach((idx) => allSelectedIndices.add(idx))
|
||||||
|
})
|
||||||
|
|
||||||
|
// For each selected index, aggregate data from workflows that have that segment selected
|
||||||
|
// If a workflow filter is active, only include that workflow's data
|
||||||
|
return Array.from(allSelectedIndices)
|
||||||
|
.sort((a, b) => a - b)
|
||||||
|
.map((idx) => {
|
||||||
|
let totalExecutions = 0
|
||||||
|
let successfulExecutions = 0
|
||||||
|
let weightedLatencySum = 0
|
||||||
|
let latencyCount = 0
|
||||||
|
const timestamp = aggregateSegments[idx]?.timestamp || ''
|
||||||
|
|
||||||
|
// Sum up data from workflows that have this segment selected
|
||||||
|
Object.entries(selectedSegments).forEach(([workflowId, indices]) => {
|
||||||
|
if (!indices.includes(idx)) return
|
||||||
|
|
||||||
|
// If workflow filter is active, skip other workflows
|
||||||
|
if (hasWorkflowFilter && workflowId !== expandedWorkflowId) return
|
||||||
|
|
||||||
|
const workflow = filteredExecutions.find((w) => w.workflowId === workflowId)
|
||||||
|
const segment = workflow?.segments[idx]
|
||||||
|
if (!segment) return
|
||||||
|
|
||||||
|
totalExecutions += segment.totalExecutions || 0
|
||||||
|
successfulExecutions += segment.successfulExecutions || 0
|
||||||
|
|
||||||
|
if (segment.avgDurationMs && segment.totalExecutions) {
|
||||||
|
weightedLatencySum += segment.avgDurationMs * segment.totalExecutions
|
||||||
|
latencyCount += segment.totalExecutions
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
timestamp,
|
||||||
|
totalExecutions,
|
||||||
|
successfulExecutions,
|
||||||
|
avgDurationMs: latencyCount > 0 ? weightedLatencySum / latencyCount : 0,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})()
|
||||||
|
: hasWorkflowFilter
|
||||||
|
? (() => {
|
||||||
|
// Filter to show only the expanded workflow's data
|
||||||
|
const workflow = filteredExecutions.find((w) => w.workflowId === expandedWorkflowId)
|
||||||
|
if (!workflow) return aggregateSegments
|
||||||
|
|
||||||
|
return workflow.segments.map((segment) => ({
|
||||||
|
timestamp: segment.timestamp,
|
||||||
|
totalExecutions: segment.totalExecutions || 0,
|
||||||
|
successfulExecutions: segment.successfulExecutions || 0,
|
||||||
|
avgDurationMs: segment.avgDurationMs ?? 0,
|
||||||
|
}))
|
||||||
|
})()
|
||||||
|
: hasActiveFilters
|
||||||
|
? (() => {
|
||||||
|
// Always recalculate aggregate segments based on filtered workflows when filters are active
|
||||||
|
return aggregateSegments.map((aggSeg, idx) => {
|
||||||
|
let totalExecutions = 0
|
||||||
|
let successfulExecutions = 0
|
||||||
|
let weightedLatencySum = 0
|
||||||
|
let latencyCount = 0
|
||||||
|
|
||||||
|
filteredExecutions.forEach((workflow) => {
|
||||||
|
const segment = workflow.segments[idx]
|
||||||
|
if (!segment) return
|
||||||
|
|
||||||
|
totalExecutions += segment.totalExecutions || 0
|
||||||
|
successfulExecutions += segment.successfulExecutions || 0
|
||||||
|
|
||||||
|
if (segment.avgDurationMs && segment.totalExecutions) {
|
||||||
|
weightedLatencySum += segment.avgDurationMs * segment.totalExecutions
|
||||||
|
latencyCount += segment.totalExecutions
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
timestamp: aggSeg.timestamp,
|
||||||
|
totalExecutions,
|
||||||
|
successfulExecutions,
|
||||||
|
avgDurationMs: latencyCount > 0 ? weightedLatencySum / latencyCount : 0,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})()
|
||||||
|
: aggregateSegments
|
||||||
|
|
||||||
|
const errorRates = segmentsToUse.map((s) => ({
|
||||||
|
timestamp: s.timestamp,
|
||||||
|
value: s.totalExecutions > 0 ? (1 - s.successfulExecutions / s.totalExecutions) * 100 : 0,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const executionCounts = segmentsToUse.map((s) => ({
|
||||||
|
timestamp: s.timestamp,
|
||||||
|
value: s.totalExecutions,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const failureCounts = segmentsToUse.map((s) => ({
|
||||||
|
timestamp: s.timestamp,
|
||||||
|
value: s.totalExecutions - s.successfulExecutions,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const latencies = segmentsToUse.map((s) => ({
|
||||||
|
timestamp: s.timestamp,
|
||||||
|
value: s.avgDurationMs ?? 0,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
errorRates,
|
||||||
|
durations: [],
|
||||||
|
executionCounts,
|
||||||
|
failureCounts,
|
||||||
|
latencies,
|
||||||
|
logs: globalLogs,
|
||||||
|
allLogs: globalLogs,
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
aggregateSegments,
|
||||||
|
globalLogs,
|
||||||
|
selectedSegments,
|
||||||
|
filteredExecutions,
|
||||||
|
expandedWorkflowId,
|
||||||
|
hasActiveFilters,
|
||||||
|
])
|
||||||
|
|
||||||
|
const workflowDetails = useMemo(() => {
|
||||||
|
if (!expandedWorkflowId || !workflowLogs.length) return {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
[expandedWorkflowId]: {
|
||||||
|
errorRates: [],
|
||||||
|
durations: [],
|
||||||
|
executionCounts: [],
|
||||||
|
logs: workflowLogs,
|
||||||
|
allLogs: workflowLogs,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}, [expandedWorkflowId, workflowLogs])
|
||||||
|
|
||||||
|
const aggregate = useMemo(() => {
|
||||||
|
const hasSelection = Object.keys(selectedSegments).length > 0
|
||||||
|
const hasWorkflowFilter = expandedWorkflowId && expandedWorkflowId !== '__multi__'
|
||||||
|
let totalExecutions = 0
|
||||||
|
let successfulExecutions = 0
|
||||||
|
let activeWorkflows = 0
|
||||||
|
let weightedLatencySum = 0
|
||||||
|
let latencyExecutionCount = 0
|
||||||
|
|
||||||
|
// Apply workflow filter first if present, otherwise use filtered executions
|
||||||
|
const workflowsToProcess = hasWorkflowFilter
|
||||||
|
? filteredExecutions.filter((wf) => wf.workflowId === expandedWorkflowId)
|
||||||
|
: filteredExecutions
|
||||||
|
|
||||||
|
for (const wf of workflowsToProcess) {
|
||||||
|
const selectedIndices = hasSelection ? selectedSegments[wf.workflowId] : null
|
||||||
|
let workflowHasExecutions = false
|
||||||
|
|
||||||
|
wf.segments.forEach((seg, idx) => {
|
||||||
|
// If segment selection exists, only count selected segments
|
||||||
|
// Otherwise, count all segments
|
||||||
|
if (!selectedIndices || selectedIndices.includes(idx)) {
|
||||||
|
const execCount = seg.totalExecutions || 0
|
||||||
|
totalExecutions += execCount
|
||||||
|
successfulExecutions += seg.successfulExecutions || 0
|
||||||
|
|
||||||
|
if (
|
||||||
|
seg.avgDurationMs !== undefined &&
|
||||||
|
seg.avgDurationMs !== null &&
|
||||||
|
seg.avgDurationMs > 0 &&
|
||||||
|
execCount > 0
|
||||||
|
) {
|
||||||
|
weightedLatencySum += seg.avgDurationMs * execCount
|
||||||
|
latencyExecutionCount += execCount
|
||||||
|
}
|
||||||
|
if (seg.hasExecutions) workflowHasExecutions = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (workflowHasExecutions) activeWorkflows += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const failedExecutions = Math.max(totalExecutions - successfulExecutions, 0)
|
||||||
|
const successRate = totalExecutions > 0 ? (successfulExecutions / totalExecutions) * 100 : 100
|
||||||
|
const avgLatency = latencyExecutionCount > 0 ? weightedLatencySum / latencyExecutionCount : 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalExecutions,
|
||||||
|
successfulExecutions,
|
||||||
|
failedExecutions,
|
||||||
|
activeWorkflows,
|
||||||
|
successRate,
|
||||||
|
avgLatency,
|
||||||
|
}
|
||||||
|
}, [filteredExecutions, selectedSegments, expandedWorkflowId])
|
||||||
|
|
||||||
|
const loadMoreLogs = useCallback(
|
||||||
|
(workflowId: string) => {
|
||||||
|
if (
|
||||||
|
workflowId === expandedWorkflowId &&
|
||||||
|
workflowLogsQuery.hasNextPage &&
|
||||||
|
!workflowLogsQuery.isFetchingNextPage
|
||||||
|
) {
|
||||||
|
workflowLogsQuery.fetchNextPage()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[expandedWorkflowId, workflowLogsQuery]
|
||||||
|
)
|
||||||
|
|
||||||
|
const loadMoreGlobalLogs = useCallback(() => {
|
||||||
|
if (globalLogsQuery.hasNextPage && !globalLogsQuery.isFetchingNextPage) {
|
||||||
|
globalLogsQuery.fetchNextPage()
|
||||||
|
}
|
||||||
|
}, [globalLogsQuery])
|
||||||
|
|
||||||
|
const toggleWorkflow = useCallback(
|
||||||
|
(workflowId: string) => {
|
||||||
|
if (expandedWorkflowId === workflowId) {
|
||||||
|
setExpandedWorkflowId(null)
|
||||||
|
setSelectedSegments({})
|
||||||
|
setLastAnchorIndices({})
|
||||||
|
} else {
|
||||||
|
setExpandedWorkflowId(workflowId)
|
||||||
|
setSelectedSegments({})
|
||||||
|
setLastAnchorIndices({})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[expandedWorkflowId]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSegmentClick = useCallback(
|
||||||
|
(
|
||||||
|
workflowId: string,
|
||||||
|
segmentIndex: number,
|
||||||
|
_timestamp: string,
|
||||||
|
mode: 'single' | 'toggle' | 'range'
|
||||||
|
) => {
|
||||||
|
if (mode === 'toggle') {
|
||||||
|
setSelectedSegments((prev) => {
|
||||||
|
const currentSegments = prev[workflowId] || []
|
||||||
|
const exists = currentSegments.includes(segmentIndex)
|
||||||
|
const nextSegments = exists
|
||||||
|
? currentSegments.filter((i) => i !== segmentIndex)
|
||||||
|
: [...currentSegments, segmentIndex].sort((a, b) => a - b)
|
||||||
|
|
||||||
|
if (nextSegments.length === 0) {
|
||||||
|
const { [workflowId]: _, ...rest } = prev
|
||||||
|
if (Object.keys(rest).length === 0) {
|
||||||
|
setExpandedWorkflowId(null)
|
||||||
|
}
|
||||||
|
return rest
|
||||||
|
}
|
||||||
|
|
||||||
|
const newState = { ...prev, [workflowId]: nextSegments }
|
||||||
|
|
||||||
|
const selectedWorkflowIds = Object.keys(newState)
|
||||||
|
if (selectedWorkflowIds.length > 1) {
|
||||||
|
setExpandedWorkflowId('__multi__')
|
||||||
|
} else if (selectedWorkflowIds.length === 1) {
|
||||||
|
setExpandedWorkflowId(selectedWorkflowIds[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
return newState
|
||||||
|
})
|
||||||
|
|
||||||
|
setLastAnchorIndices((prev) => ({ ...prev, [workflowId]: segmentIndex }))
|
||||||
|
} else if (mode === 'single') {
|
||||||
|
setSelectedSegments((prev) => {
|
||||||
|
const currentSegments = prev[workflowId] || []
|
||||||
|
const isOnlySelectedSegment =
|
||||||
|
currentSegments.length === 1 && currentSegments[0] === segmentIndex
|
||||||
|
const isOnlyWorkflowSelected = Object.keys(prev).length === 1 && prev[workflowId]
|
||||||
|
|
||||||
|
if (isOnlySelectedSegment && isOnlyWorkflowSelected) {
|
||||||
|
setExpandedWorkflowId(null)
|
||||||
|
setLastAnchorIndices({})
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
setExpandedWorkflowId(workflowId)
|
||||||
|
setLastAnchorIndices({ [workflowId]: segmentIndex })
|
||||||
|
return { [workflowId]: [segmentIndex] }
|
||||||
|
})
|
||||||
|
} else if (mode === 'range') {
|
||||||
|
if (expandedWorkflowId === workflowId) {
|
||||||
|
setSelectedSegments((prev) => {
|
||||||
|
const currentSegments = prev[workflowId] || []
|
||||||
|
const anchor = lastAnchorIndices[workflowId] ?? segmentIndex
|
||||||
|
const [start, end] =
|
||||||
|
anchor < segmentIndex ? [anchor, segmentIndex] : [segmentIndex, anchor]
|
||||||
|
const range = Array.from({ length: end - start + 1 }, (_, i) => start + i)
|
||||||
|
const union = new Set([...currentSegments, ...range])
|
||||||
|
return { ...prev, [workflowId]: Array.from(union).sort((a, b) => a - b) }
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setExpandedWorkflowId(workflowId)
|
||||||
|
setSelectedSegments({ [workflowId]: [segmentIndex] })
|
||||||
|
setLastAnchorIndices({ [workflowId]: segmentIndex })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[expandedWorkflowId, lastAnchorIndices]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Update endTime when filters change to ensure consistent time ranges with logs view
|
||||||
|
useEffect(() => {
|
||||||
|
setEndTime(new Date())
|
||||||
|
setSelectedSegments({})
|
||||||
|
setLastAnchorIndices({})
|
||||||
|
}, [timeFilter, workflowIds, folderIds, triggers, level, searchQuery])
|
||||||
|
|
||||||
|
// Clear expanded workflow if it's no longer in filtered executions
|
||||||
|
useEffect(() => {
|
||||||
|
if (expandedWorkflowId && expandedWorkflowId !== '__multi__') {
|
||||||
|
const isStillVisible = filteredExecutions.some((wf) => wf.workflowId === expandedWorkflowId)
|
||||||
|
if (!isStillVisible) {
|
||||||
|
setExpandedWorkflowId(null)
|
||||||
|
setSelectedSegments({})
|
||||||
|
setLastAnchorIndices({})
|
||||||
|
}
|
||||||
|
} else if (expandedWorkflowId === '__multi__') {
|
||||||
|
// Check if any of the selected workflows are still visible
|
||||||
|
const selectedWorkflowIds = Object.keys(selectedSegments)
|
||||||
|
const stillVisibleIds = selectedWorkflowIds.filter((id) =>
|
||||||
|
filteredExecutions.some((wf) => wf.workflowId === id)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (stillVisibleIds.length === 0) {
|
||||||
|
setExpandedWorkflowId(null)
|
||||||
|
setSelectedSegments({})
|
||||||
|
setLastAnchorIndices({})
|
||||||
|
} else if (stillVisibleIds.length !== selectedWorkflowIds.length) {
|
||||||
|
// Remove segments for workflows that are no longer visible
|
||||||
|
const updatedSegments: Record<string, number[]> = {}
|
||||||
|
stillVisibleIds.forEach((id) => {
|
||||||
|
if (selectedSegments[id]) {
|
||||||
|
updatedSegments[id] = selectedSegments[id]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setSelectedSegments(updatedSegments)
|
||||||
|
|
||||||
|
if (stillVisibleIds.length === 1) {
|
||||||
|
setExpandedWorkflowId(stillVisibleIds[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [filteredExecutions, expandedWorkflowId, selectedSegments])
|
||||||
|
|
||||||
|
// Notify parent when custom time range is active
|
||||||
|
useEffect(() => {
|
||||||
|
const hasCustomRange = Object.keys(selectedSegments).length > 0
|
||||||
|
onCustomTimeRangeChange?.(hasCustomRange)
|
||||||
|
}, [selectedSegments, onCustomTimeRangeChange])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!barsAreaRef.current) return
|
||||||
|
const el = barsAreaRef.current
|
||||||
|
let debounceId: ReturnType<typeof setTimeout> | null = null
|
||||||
|
const ro = new ResizeObserver(([entry]) => {
|
||||||
|
const w = entry?.contentRect?.width || 720
|
||||||
|
const n = Math.max(36, Math.min(96, Math.floor(w / MIN_SEGMENT_PX)))
|
||||||
|
if (debounceId) clearTimeout(debounceId)
|
||||||
|
debounceId = setTimeout(() => {
|
||||||
|
setSegmentCount(n)
|
||||||
|
}, 150)
|
||||||
|
})
|
||||||
|
ro.observe(el)
|
||||||
|
const rect = el.getBoundingClientRect()
|
||||||
|
if (rect?.width) {
|
||||||
|
const n = Math.max(36, Math.min(96, Math.floor(rect.width / MIN_SEGMENT_PX)))
|
||||||
|
setSegmentCount(n)
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (debounceId) clearTimeout(debounceId)
|
||||||
|
ro.disconnect()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Live mode: refresh endTime periodically
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLive) return
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setEndTime(new Date())
|
||||||
|
}, 5000)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [isLive])
|
||||||
|
|
||||||
|
// Refresh when trigger changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (refreshTrigger > 0) {
|
||||||
|
setEndTime(new Date())
|
||||||
|
}
|
||||||
|
}, [refreshTrigger])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <DashboardSkeleton />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className='mt-[24px] flex flex-1 items-center justify-center'>
|
||||||
|
<div className='text-[var(--text-error)]'>
|
||||||
|
<p className='font-medium text-[13px]'>Error loading data</p>
|
||||||
|
<p className='text-[12px]'>{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (executions.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className='mt-[24px] flex flex-1 items-center justify-center'>
|
||||||
|
<div className='text-center text-[var(--text-secondary)]'>
|
||||||
|
<p className='font-medium text-[13px]'>No execution history</p>
|
||||||
|
<p className='mt-[4px] text-[12px]'>Execute some workflows to see their history here</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='mt-[24px] flex min-h-0 flex-1 flex-col'>
|
||||||
|
{/* Graphs Section */}
|
||||||
|
<div className='mb-[16px] flex-shrink-0'>
|
||||||
|
<div className='grid grid-cols-1 gap-[16px] md:grid-cols-3'>
|
||||||
|
{/* Runs Graph */}
|
||||||
|
<div className='flex flex-col overflow-hidden rounded-[6px] bg-[var(--surface-elevated)]'>
|
||||||
|
<div className='flex min-w-0 items-center justify-between gap-[8px] bg-[var(--surface-3)] px-[16px] py-[9px]'>
|
||||||
|
<span className='min-w-0 truncate font-medium text-[var(--text-primary)] text-sm'>
|
||||||
|
Runs
|
||||||
|
</span>
|
||||||
|
{globalDetails && globalDetails.executionCounts.length > 0 && (
|
||||||
|
<span className='flex-shrink-0 font-medium text-[var(--text-secondary)] text-sm'>
|
||||||
|
{aggregate.totalExecutions}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className='flex-1 overflow-y-auto rounded-t-[6px] bg-[var(--surface-1)] px-[14px] py-[10px]'>
|
||||||
|
{globalDetails ? (
|
||||||
|
<LineChart
|
||||||
|
key={`runs-${expandedWorkflowId || 'all'}-${Object.keys(selectedSegments).length}-${filteredExecutions.length}`}
|
||||||
|
data={globalDetails.executionCounts}
|
||||||
|
label=''
|
||||||
|
color='var(--brand-tertiary)'
|
||||||
|
unit=''
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className='flex h-[166px] items-center justify-center'>
|
||||||
|
<Loader2 className='h-[16px] w-[16px] animate-spin text-[var(--text-secondary)]' />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Errors Graph */}
|
||||||
|
<div className='flex flex-col overflow-hidden rounded-[6px] bg-[var(--surface-elevated)]'>
|
||||||
|
<div className='flex min-w-0 items-center justify-between gap-[8px] bg-[var(--surface-3)] px-[16px] py-[9px]'>
|
||||||
|
<span className='min-w-0 truncate font-medium text-[var(--text-primary)] text-sm'>
|
||||||
|
Errors
|
||||||
|
</span>
|
||||||
|
{globalDetails && globalDetails.failureCounts.length > 0 && (
|
||||||
|
<span className='flex-shrink-0 font-medium text-[var(--text-secondary)] text-sm'>
|
||||||
|
{aggregate.failedExecutions}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className='flex-1 overflow-y-auto rounded-t-[6px] bg-[var(--surface-1)] px-[14px] py-[10px]'>
|
||||||
|
{globalDetails ? (
|
||||||
|
<LineChart
|
||||||
|
key={`errors-${expandedWorkflowId || 'all'}-${Object.keys(selectedSegments).length}-${filteredExecutions.length}`}
|
||||||
|
data={globalDetails.failureCounts}
|
||||||
|
label=''
|
||||||
|
color='var(--text-error)'
|
||||||
|
unit=''
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className='flex h-[166px] items-center justify-center'>
|
||||||
|
<Loader2 className='h-[16px] w-[16px] animate-spin text-[var(--text-secondary)]' />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Latency Graph */}
|
||||||
|
<div className='flex flex-col overflow-hidden rounded-[6px] bg-[var(--surface-elevated)]'>
|
||||||
|
<div className='flex min-w-0 items-center justify-between gap-[8px] bg-[var(--surface-3)] px-[16px] py-[9px]'>
|
||||||
|
<span className='min-w-0 truncate font-medium text-[var(--text-primary)] text-sm'>
|
||||||
|
Latency
|
||||||
|
</span>
|
||||||
|
{globalDetails && globalDetails.latencies.length > 0 && (
|
||||||
|
<span className='flex-shrink-0 font-medium text-[var(--text-secondary)] text-sm'>
|
||||||
|
{formatLatency(aggregate.avgLatency)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className='flex-1 overflow-y-auto rounded-t-[6px] bg-[var(--surface-1)] px-[14px] py-[10px]'>
|
||||||
|
{globalDetails ? (
|
||||||
|
<LineChart
|
||||||
|
key={`latency-${expandedWorkflowId || 'all'}-${Object.keys(selectedSegments).length}-${filteredExecutions.length}`}
|
||||||
|
data={globalDetails.latencies}
|
||||||
|
label=''
|
||||||
|
color='var(--c-F59E0B)'
|
||||||
|
unit='latency'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className='flex h-[166px] items-center justify-center'>
|
||||||
|
<Loader2 className='h-[16px] w-[16px] animate-spin text-[var(--text-secondary)]' />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Workflows Table - takes remaining space */}
|
||||||
|
<div className='min-h-0 flex-1 overflow-hidden' ref={barsAreaRef}>
|
||||||
|
<WorkflowsList
|
||||||
|
executions={executions as WorkflowExecution[]}
|
||||||
|
filteredExecutions={filteredExecutions as WorkflowExecution[]}
|
||||||
|
expandedWorkflowId={expandedWorkflowId}
|
||||||
|
onToggleWorkflow={toggleWorkflow}
|
||||||
|
selectedSegments={selectedSegments}
|
||||||
|
onSegmentClick={handleSegmentClick}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
segmentDurationMs={
|
||||||
|
(endTime.getTime() - getStartTime().getTime()) / Math.max(1, segmentCount)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default, default as Dashboard } from './dashboard'
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
export interface AggregateMetrics {
|
|
||||||
totalExecutions: number
|
|
||||||
successfulExecutions: number
|
|
||||||
failedExecutions: number
|
|
||||||
activeWorkflows: number
|
|
||||||
successRate: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export function KPIs({ aggregate }: { aggregate: AggregateMetrics }) {
|
|
||||||
return (
|
|
||||||
<div className='mb-2 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4'>
|
|
||||||
<div className='rounded-[11px] border bg-card p-4 shadow-sm'>
|
|
||||||
<div className='text-muted-foreground text-xs'>Total executions</div>
|
|
||||||
<div className='mt-1 font-[440] text-[22px] leading-6'>
|
|
||||||
{aggregate.totalExecutions.toLocaleString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className='rounded-[11px] border bg-card p-4 shadow-sm'>
|
|
||||||
<div className='text-muted-foreground text-xs'>Success rate</div>
|
|
||||||
<div className='mt-1 font-[440] text-[22px] leading-6'>
|
|
||||||
{aggregate.successRate.toFixed(1)}%
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className='rounded-[11px] border bg-card p-4 shadow-sm'>
|
|
||||||
<div className='text-muted-foreground text-xs'>Failed executions</div>
|
|
||||||
<div className='mt-1 font-[440] text-[22px] leading-6'>
|
|
||||||
{aggregate.failedExecutions.toLocaleString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className='rounded-[11px] border bg-card p-4 shadow-sm'>
|
|
||||||
<div className='text-muted-foreground text-xs'>Active workflows</div>
|
|
||||||
<div className='mt-1 font-[440] text-[22px] leading-6'>{aggregate.activeWorkflows}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default KPIs
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
export const getTriggerColor = (trigger: string | null | undefined): string => {
|
|
||||||
if (!trigger) return '#9ca3af'
|
|
||||||
switch (trigger.toLowerCase()) {
|
|
||||||
case 'manual':
|
|
||||||
return '#9ca3af'
|
|
||||||
case 'schedule':
|
|
||||||
return '#10b981'
|
|
||||||
case 'webhook':
|
|
||||||
return '#f97316'
|
|
||||||
case 'chat':
|
|
||||||
return '#8b5cf6'
|
|
||||||
case 'api':
|
|
||||||
return '#3b82f6'
|
|
||||||
default:
|
|
||||||
return '#9ca3af'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,624 +0,0 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
||||||
import { ArrowUpRight, Info, Loader2 } from 'lucide-react'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import { useRouter } from 'next/navigation'
|
|
||||||
import { highlight, languages } from 'prismjs'
|
|
||||||
import 'prismjs/components/prism-javascript'
|
|
||||||
import 'prismjs/components/prism-python'
|
|
||||||
import 'prismjs/components/prism-json'
|
|
||||||
import { CopyButton } from '@/components/ui/copy-button'
|
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
|
||||||
import LineChart, {
|
|
||||||
type LineChartPoint,
|
|
||||||
} from '@/app/workspace/[workspaceId]/logs/components/dashboard/line-chart'
|
|
||||||
import { getTriggerColor } from '@/app/workspace/[workspaceId]/logs/components/dashboard/utils'
|
|
||||||
import LogMarkdownRenderer from '@/app/workspace/[workspaceId]/logs/components/sidebar/components/markdown-renderer'
|
|
||||||
import { formatDate } from '@/app/workspace/[workspaceId]/logs/utils'
|
|
||||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
|
||||||
import '@/components/emcn/components/code/code.css'
|
|
||||||
|
|
||||||
export interface ExecutionLogItem {
|
|
||||||
id: string
|
|
||||||
executionId: string
|
|
||||||
startedAt: string
|
|
||||||
level: string
|
|
||||||
trigger: string
|
|
||||||
triggerUserId: string | null
|
|
||||||
triggerInputs: any
|
|
||||||
outputs: any
|
|
||||||
errorMessage: string | null
|
|
||||||
duration: number | null
|
|
||||||
cost: {
|
|
||||||
input: number
|
|
||||||
output: number
|
|
||||||
total: number
|
|
||||||
} | null
|
|
||||||
workflowName?: string
|
|
||||||
workflowColor?: string
|
|
||||||
hasPendingPause?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tries to parse a string as JSON and prettify it
|
|
||||||
*/
|
|
||||||
const tryPrettifyJson = (content: string): { isJson: boolean; formatted: string } => {
|
|
||||||
try {
|
|
||||||
const trimmed = content.trim()
|
|
||||||
if (
|
|
||||||
!(trimmed.startsWith('{') || trimmed.startsWith('[')) ||
|
|
||||||
!(trimmed.endsWith('}') || trimmed.endsWith(']'))
|
|
||||||
) {
|
|
||||||
return { isJson: false, formatted: content }
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = JSON.parse(trimmed)
|
|
||||||
const prettified = JSON.stringify(parsed, null, 2)
|
|
||||||
return { isJson: true, formatted: prettified }
|
|
||||||
} catch (_e) {
|
|
||||||
return { isJson: false, formatted: content }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WorkflowDetailsData {
|
|
||||||
errorRates: LineChartPoint[]
|
|
||||||
durations?: LineChartPoint[]
|
|
||||||
durationP50?: LineChartPoint[]
|
|
||||||
durationP90?: LineChartPoint[]
|
|
||||||
durationP99?: LineChartPoint[]
|
|
||||||
executionCounts: LineChartPoint[]
|
|
||||||
logs: ExecutionLogItem[]
|
|
||||||
allLogs: ExecutionLogItem[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WorkflowDetails({
|
|
||||||
workspaceId,
|
|
||||||
expandedWorkflowId,
|
|
||||||
workflowName,
|
|
||||||
overview,
|
|
||||||
details,
|
|
||||||
selectedSegmentIndex,
|
|
||||||
selectedSegment,
|
|
||||||
selectedSegmentTimeRange,
|
|
||||||
selectedWorkflowNames,
|
|
||||||
segmentDurationMs,
|
|
||||||
clearSegmentSelection,
|
|
||||||
formatCost,
|
|
||||||
onLoadMore,
|
|
||||||
hasMore,
|
|
||||||
isLoadingMore,
|
|
||||||
}: {
|
|
||||||
workspaceId: string
|
|
||||||
expandedWorkflowId: string
|
|
||||||
workflowName: string
|
|
||||||
overview: { total: number; success: number; failures: number; rate: number }
|
|
||||||
details: WorkflowDetailsData | undefined
|
|
||||||
selectedSegmentIndex: number[] | null
|
|
||||||
selectedSegment: { timestamp: string; totalExecutions: number } | null
|
|
||||||
selectedSegmentTimeRange?: { start: Date; end: Date } | null
|
|
||||||
selectedWorkflowNames?: string[]
|
|
||||||
segmentDurationMs?: number
|
|
||||||
clearSegmentSelection: () => void
|
|
||||||
formatCost: (n: number) => string
|
|
||||||
onLoadMore?: () => void
|
|
||||||
hasMore?: boolean
|
|
||||||
isLoadingMore?: boolean
|
|
||||||
}) {
|
|
||||||
const router = useRouter()
|
|
||||||
const { workflows } = useWorkflowRegistry()
|
|
||||||
|
|
||||||
// Check if any logs have pending status to show Resume column
|
|
||||||
const hasPendingExecutions = useMemo(() => {
|
|
||||||
return details?.logs?.some((log) => log.hasPendingPause === true) || false
|
|
||||||
}, [details])
|
|
||||||
|
|
||||||
const workflowColor = useMemo(
|
|
||||||
() => workflows[expandedWorkflowId]?.color || '#3972F6',
|
|
||||||
[workflows, expandedWorkflowId]
|
|
||||||
)
|
|
||||||
const [expandedRowId, setExpandedRowId] = useState<string | null>(null)
|
|
||||||
const listRef = useRef<HTMLDivElement | null>(null)
|
|
||||||
const loaderRef = useRef<HTMLDivElement | null>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const rootEl = listRef.current
|
|
||||||
const sentinel = loaderRef.current
|
|
||||||
if (!rootEl || !sentinel || !onLoadMore || !hasMore) return
|
|
||||||
|
|
||||||
let ticking = false
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
(entries) => {
|
|
||||||
const entry = entries[0]
|
|
||||||
if (entry?.isIntersecting && hasMore && !ticking && !isLoadingMore) {
|
|
||||||
ticking = true
|
|
||||||
setTimeout(() => {
|
|
||||||
onLoadMore()
|
|
||||||
ticking = false
|
|
||||||
}, 50)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ root: rootEl, threshold: 0.1, rootMargin: '200px 0px 0px 0px' }
|
|
||||||
)
|
|
||||||
|
|
||||||
observer.observe(sentinel)
|
|
||||||
return () => observer.disconnect()
|
|
||||||
}, [onLoadMore, hasMore, isLoadingMore])
|
|
||||||
|
|
||||||
// Fallback: if IntersectionObserver fails (older browsers), use scroll position
|
|
||||||
useEffect(() => {
|
|
||||||
const el = listRef.current
|
|
||||||
if (!el || !onLoadMore || !hasMore) return
|
|
||||||
|
|
||||||
const onScroll = () => {
|
|
||||||
const { scrollTop, scrollHeight, clientHeight } = el
|
|
||||||
const pct = (scrollTop / Math.max(1, scrollHeight - clientHeight)) * 100
|
|
||||||
if (pct > 80 && !isLoadingMore) onLoadMore()
|
|
||||||
}
|
|
||||||
el.addEventListener('scroll', onScroll)
|
|
||||||
return () => el.removeEventListener('scroll', onScroll)
|
|
||||||
}, [onLoadMore, hasMore, isLoadingMore])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='mt-1 overflow-hidden rounded-[11px] border bg-card shadow-sm'>
|
|
||||||
<div className='border-b bg-muted/30 px-4 py-2.5'>
|
|
||||||
<div className='flex items-center justify-between'>
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
{expandedWorkflowId !== 'all' && expandedWorkflowId !== '__multi__' ? (
|
|
||||||
<button
|
|
||||||
onClick={() => router.push(`/workspace/${workspaceId}/w/${expandedWorkflowId}`)}
|
|
||||||
className='group inline-flex items-center gap-2 text-left transition-opacity hover:opacity-70'
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className='h-[14px] w-[14px] flex-shrink-0 rounded'
|
|
||||||
style={{ backgroundColor: workflowColor }}
|
|
||||||
/>
|
|
||||||
<span className='font-[480] text-sm tracking-tight dark:font-[560]'>
|
|
||||||
{workflowName}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<div className='inline-flex items-center gap-2'>
|
|
||||||
<span
|
|
||||||
className='h-[14px] w-[14px] flex-shrink-0 rounded'
|
|
||||||
style={{ backgroundColor: workflowColor }}
|
|
||||||
/>
|
|
||||||
<span className='font-[480] text-sm tracking-tight dark:font-[560]'>
|
|
||||||
{workflowName}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{Array.isArray(selectedSegmentIndex) &&
|
|
||||||
selectedSegmentIndex.length > 0 &&
|
|
||||||
(selectedSegment || selectedSegmentTimeRange || expandedWorkflowId === '__multi__') &&
|
|
||||||
(() => {
|
|
||||||
let tsLabel = 'Selected segment'
|
|
||||||
if (selectedSegmentTimeRange) {
|
|
||||||
const start = selectedSegmentTimeRange.start
|
|
||||||
const end = selectedSegmentTimeRange.end
|
|
||||||
const startFormatted = start.toLocaleString('en-US', {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: '2-digit',
|
|
||||||
hour12: true,
|
|
||||||
})
|
|
||||||
const endFormatted = end.toLocaleString('en-US', {
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: '2-digit',
|
|
||||||
hour12: true,
|
|
||||||
})
|
|
||||||
tsLabel = `${startFormatted} – ${endFormatted}`
|
|
||||||
} else if (selectedSegment?.timestamp) {
|
|
||||||
const tsObj = new Date(selectedSegment.timestamp)
|
|
||||||
if (!Number.isNaN(tsObj.getTime())) {
|
|
||||||
tsLabel = tsObj.toLocaleString('en-US', {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: '2-digit',
|
|
||||||
hour12: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const isMultiWorkflow =
|
|
||||||
expandedWorkflowId === '__multi__' &&
|
|
||||||
selectedWorkflowNames &&
|
|
||||||
selectedWorkflowNames.length > 0
|
|
||||||
const workflowLabel = isMultiWorkflow
|
|
||||||
? selectedWorkflowNames.length <= 2
|
|
||||||
? selectedWorkflowNames.join(', ')
|
|
||||||
: `${selectedWorkflowNames.slice(0, 2).join(', ')} +${selectedWorkflowNames.length - 2}`
|
|
||||||
: null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='inline-flex h-7 items-center gap-1.5 rounded-md border bg-muted/50 px-2.5'>
|
|
||||||
{isMultiWorkflow && workflowLabel && (
|
|
||||||
<span className='font-medium text-[11px] text-muted-foreground'>
|
|
||||||
{workflowLabel}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className='font-medium text-[11px] text-foreground'>
|
|
||||||
{tsLabel}
|
|
||||||
{selectedSegmentIndex.length > 1 && !isMultiWorkflow
|
|
||||||
? ` (+${selectedSegmentIndex.length - 1})`
|
|
||||||
: ''}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
clearSegmentSelection()
|
|
||||||
}}
|
|
||||||
className='ml-0.5 flex h-4 w-4 items-center justify-center rounded text-muted-foreground text-xs transition-colors hover:bg-muted hover:text-foreground focus:outline-none focus:ring-1 focus:ring-primary/40'
|
|
||||||
aria-label='Clear filter'
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<div className='inline-flex h-7 items-center gap-2 rounded border px-2.5'>
|
|
||||||
<span className='text-[11px] text-muted-foreground'>Executions</span>
|
|
||||||
<span className='font-[500] text-sm leading-none'>{overview.total}</span>
|
|
||||||
</div>
|
|
||||||
<div className='inline-flex h-7 items-center gap-2 rounded border px-2.5'>
|
|
||||||
<span className='text-[11px] text-muted-foreground'>Success</span>
|
|
||||||
<span className='font-[500] text-sm leading-none'>{overview.rate.toFixed(1)}%</span>
|
|
||||||
</div>
|
|
||||||
<div className='inline-flex h-7 items-center gap-2 rounded border px-2.5'>
|
|
||||||
<span className='text-[11px] text-muted-foreground'>Failures</span>
|
|
||||||
<span className='font-[500] text-sm leading-none'>{overview.failures}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className='p-4'>
|
|
||||||
{details ? (
|
|
||||||
<>
|
|
||||||
{(() => {
|
|
||||||
const hasDuration = Array.isArray(details.durations) && details.durations.length > 0
|
|
||||||
const gridCols = hasDuration
|
|
||||||
? 'md:grid-cols-2 xl:grid-cols-4'
|
|
||||||
: 'md:grid-cols-2 xl:grid-cols-3'
|
|
||||||
const gridGap = hasDuration ? 'gap-2 xl:gap-2.5' : 'gap-3'
|
|
||||||
return (
|
|
||||||
<div className={`mb-3 grid grid-cols-1 ${gridGap} ${gridCols}`}>
|
|
||||||
<LineChart
|
|
||||||
data={details.errorRates}
|
|
||||||
label='Error Rate'
|
|
||||||
color='var(--text-error)'
|
|
||||||
unit='%'
|
|
||||||
/>
|
|
||||||
{hasDuration && (
|
|
||||||
<LineChart
|
|
||||||
data={details.durations!}
|
|
||||||
label='Duration'
|
|
||||||
color='#3b82f6'
|
|
||||||
unit='ms'
|
|
||||||
series={
|
|
||||||
[
|
|
||||||
details.durationP50
|
|
||||||
? {
|
|
||||||
id: 'p50',
|
|
||||||
label: 'p50',
|
|
||||||
color: '#60A5FA',
|
|
||||||
data: details.durationP50,
|
|
||||||
dashed: true,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
details.durationP90
|
|
||||||
? {
|
|
||||||
id: 'p90',
|
|
||||||
label: 'p90',
|
|
||||||
color: '#3B82F6',
|
|
||||||
data: details.durationP90,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
details.durationP99
|
|
||||||
? {
|
|
||||||
id: 'p99',
|
|
||||||
label: 'p99',
|
|
||||||
color: '#1D4ED8',
|
|
||||||
data: details.durationP99,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
].filter(Boolean) as any
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<LineChart
|
|
||||||
data={details.executionCounts}
|
|
||||||
label='Executions'
|
|
||||||
color='#10b981'
|
|
||||||
unit='execs'
|
|
||||||
/>
|
|
||||||
{(() => {
|
|
||||||
const failures = details.errorRates.map((e, i) => ({
|
|
||||||
timestamp: e.timestamp,
|
|
||||||
value: ((e.value || 0) / 100) * (details.executionCounts[i]?.value || 0),
|
|
||||||
}))
|
|
||||||
return <LineChart data={failures} label='Failures' color='#f59e0b' unit='' />
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
|
|
||||||
<div className='flex flex-1 flex-col overflow-hidden'>
|
|
||||||
<div className='w-full overflow-x-auto'>
|
|
||||||
<div>
|
|
||||||
<div className='border-b-0'>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'grid min-w-[980px] gap-2 px-2 pb-3 md:gap-3 lg:min-w-0 lg:gap-4',
|
|
||||||
hasPendingExecutions
|
|
||||||
? 'grid-cols-[140px_90px_90px_90px_180px_1fr_100px_40px]'
|
|
||||||
: 'grid-cols-[140px_90px_90px_90px_180px_1fr_100px]'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className='font-[460] font-sans text-[13px] text-muted-foreground leading-normal'>
|
|
||||||
Time
|
|
||||||
</div>
|
|
||||||
<div className='font-[460] font-sans text-[13px] text-muted-foreground leading-normal'>
|
|
||||||
Status
|
|
||||||
</div>
|
|
||||||
<div className='font-[460] font-sans text-[13px] text-muted-foreground leading-normal'>
|
|
||||||
Trigger
|
|
||||||
</div>
|
|
||||||
<div className='font-[480] font-sans text-[13px] text-muted-foreground leading-normal'>
|
|
||||||
Cost
|
|
||||||
</div>
|
|
||||||
<div className='font-[480] font-sans text-[13px] text-muted-foreground leading-normal'>
|
|
||||||
Workflow
|
|
||||||
</div>
|
|
||||||
<div className='font-[480] font-sans text-[13px] text-muted-foreground leading-normal'>
|
|
||||||
Output
|
|
||||||
</div>
|
|
||||||
<div className='text-right font-[480] font-sans text-[13px] text-muted-foreground leading-normal'>
|
|
||||||
Duration
|
|
||||||
</div>
|
|
||||||
{hasPendingExecutions && (
|
|
||||||
<div className='text-right font-[480] font-sans text-[13px] text-muted-foreground leading-normal'>
|
|
||||||
Resume
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div ref={listRef} className='flex-1 overflow-auto' style={{ maxHeight: '400px' }}>
|
|
||||||
<div className='pb-4'>
|
|
||||||
{(() => {
|
|
||||||
const logsToDisplay = details.logs
|
|
||||||
|
|
||||||
if (logsToDisplay.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className='flex h-full items-center justify-center py-8'>
|
|
||||||
<div className='flex items-center gap-2 text-muted-foreground'>
|
|
||||||
<Info className='h-5 w-5' />
|
|
||||||
<span className='text-sm'>
|
|
||||||
No executions found in this time segment
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return logsToDisplay.map((log) => {
|
|
||||||
const logDate = log?.startedAt ? new Date(log.startedAt) : null
|
|
||||||
const formattedDate =
|
|
||||||
logDate && !Number.isNaN(logDate.getTime())
|
|
||||||
? formatDate(logDate.toISOString())
|
|
||||||
: ({ compactDate: '—', compactTime: '' } as any)
|
|
||||||
const outputsStr = log.outputs ? JSON.stringify(log.outputs) : '—'
|
|
||||||
const errorStr = log.errorMessage || ''
|
|
||||||
const isExpanded = expandedRowId === log.id
|
|
||||||
const baseLevel = (log.level || 'info').toLowerCase()
|
|
||||||
const isPending = log.hasPendingPause === true
|
|
||||||
const isError = baseLevel === 'error'
|
|
||||||
const statusLabel = isPending
|
|
||||||
? 'Pending'
|
|
||||||
: `${baseLevel.charAt(0).toUpperCase()}${baseLevel.slice(1)}`
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={log.id}
|
|
||||||
className={cn(
|
|
||||||
'cursor-pointer transition-all duration-200',
|
|
||||||
isExpanded ? 'bg-accent/30' : 'hover:bg-accent/20'
|
|
||||||
)}
|
|
||||||
onClick={() =>
|
|
||||||
setExpandedRowId((prev) => (prev === log.id ? null : log.id))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'grid min-w-[980px] items-center gap-2 px-2 py-3 md:gap-3 lg:min-w-0 lg:gap-4',
|
|
||||||
hasPendingExecutions
|
|
||||||
? 'grid-cols-[140px_90px_90px_90px_180px_1fr_100px_40px]'
|
|
||||||
: 'grid-cols-[140px_90px_90px_90px_180px_1fr_100px]'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div className='text-[13px]'>
|
|
||||||
<span className='font-sm text-muted-foreground'>
|
|
||||||
{formattedDate.compactDate}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
style={{ marginLeft: '8px' }}
|
|
||||||
className='hidden font-[400] sm:inline'
|
|
||||||
>
|
|
||||||
{formattedDate.compactTime}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
{isError || !isPending ? (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex h-[24px] w-[56px] items-center justify-start rounded-[6px] border pl-[9px]',
|
|
||||||
isError
|
|
||||||
? 'gap-[5px] border-[#883827] bg-[#491515]'
|
|
||||||
: 'gap-[8px] border-[#686868] bg-[#383838]'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className='h-[6px] w-[6px] rounded-[2px]'
|
|
||||||
style={{
|
|
||||||
backgroundColor: isError ? 'var(--text-error)' : '#B7B7B7',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className='font-medium text-[11.5px]'
|
|
||||||
style={{ color: isError ? 'var(--text-error)' : '#B7B7B7' }}
|
|
||||||
>
|
|
||||||
{statusLabel}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className='inline-flex items-center bg-amber-300 px-[6px] py-[2px] font-[400] text-amber-900 text-xs dark:bg-amber-500/90 dark:text-black'>
|
|
||||||
{statusLabel}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
{log.trigger ? (
|
|
||||||
<div
|
|
||||||
className='inline-flex items-center rounded-[6px] px-[6px] py-[2px] font-[400] text-white text-xs lg:px-[8px]'
|
|
||||||
style={{ backgroundColor: getTriggerColor(log.trigger) }}
|
|
||||||
>
|
|
||||||
{log.trigger}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className='text-muted-foreground text-xs'>—</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className='font-[400] text-muted-foreground text-xs'>
|
|
||||||
{log.cost && log.cost.total > 0 ? formatCost(log.cost.total) : '—'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Workflow cell */}
|
|
||||||
<div className='whitespace-nowrap'>
|
|
||||||
{log.workflowName ? (
|
|
||||||
<div className='inline-flex items-center gap-2'>
|
|
||||||
<span
|
|
||||||
className='h-3.5 w-3.5 flex-shrink-0 rounded'
|
|
||||||
style={{ backgroundColor: log.workflowColor || '#64748b' }}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className='max-w-[150px] truncate text-muted-foreground text-xs'
|
|
||||||
title={log.workflowName}
|
|
||||||
>
|
|
||||||
{log.workflowName}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className='text-muted-foreground text-xs'>—</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Output cell */}
|
|
||||||
<div className='min-w-0 truncate whitespace-nowrap pr-2 text-[13px] text-muted-foreground'>
|
|
||||||
{log.level === 'error' && errorStr ? (
|
|
||||||
<span className='font-medium text-red-500 dark:text-red-400'>
|
|
||||||
{errorStr}
|
|
||||||
</span>
|
|
||||||
) : outputsStr.length > 220 ? (
|
|
||||||
`${outputsStr.slice(0, 217)}…`
|
|
||||||
) : (
|
|
||||||
outputsStr
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='text-right'>
|
|
||||||
<div className='text-muted-foreground text-xs tabular-nums'>
|
|
||||||
{typeof log.duration === 'number' ? `${log.duration}ms` : '—'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasPendingExecutions && (
|
|
||||||
<div className='flex justify-end'>
|
|
||||||
{isPending && log.executionId ? (
|
|
||||||
<Link
|
|
||||||
href={`/resume/${expandedWorkflowId}/${log.executionId}`}
|
|
||||||
className='inline-flex h-7 w-7 items-center justify-center border border-primary/60 border-dashed text-primary hover:bg-primary/10'
|
|
||||||
aria-label='Open resume console'
|
|
||||||
>
|
|
||||||
<ArrowUpRight className='h-4 w-4' />
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<span className='h-7 w-7' />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{isExpanded && (
|
|
||||||
<div className='px-2 pt-0 pb-4'>
|
|
||||||
<div className='group relative w-full rounded-[4px] border border-[var(--border-strong)] bg-[#1F1F1F] p-3'>
|
|
||||||
<CopyButton
|
|
||||||
text={log.level === 'error' && errorStr ? errorStr : outputsStr}
|
|
||||||
className='z-10 h-7 w-7'
|
|
||||||
/>
|
|
||||||
{(() => {
|
|
||||||
const content =
|
|
||||||
log.level === 'error' && errorStr ? errorStr : outputsStr
|
|
||||||
const { isJson, formatted } = tryPrettifyJson(content)
|
|
||||||
|
|
||||||
return isJson ? (
|
|
||||||
<div className='code-editor-theme'>
|
|
||||||
<pre
|
|
||||||
className='max-h-[300px] w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-all font-mono text-[#eeeeee] text-[11px] leading-[16px]'
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: highlight(formatted, languages.json, 'json'),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className='max-h-[300px] overflow-y-auto'>
|
|
||||||
<LogMarkdownRenderer content={formatted} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})()}
|
|
||||||
{/* Bottom loading / sentinel */}
|
|
||||||
{hasMore && details.logs.length > 0 && (
|
|
||||||
<div className='flex items-center justify-center py-3 text-muted-foreground'>
|
|
||||||
<div ref={loaderRef} className='flex items-center gap-2'>
|
|
||||||
{isLoadingMore ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className='h-4 w-4 animate-spin' />
|
|
||||||
<span className='text-sm'>Loading more…</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span className='text-sm'>Scroll to load more</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className='flex items-center justify-center py-12'>
|
|
||||||
<Loader2 className='h-6 w-6 animate-spin text-muted-foreground' />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default WorkflowDetails
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
import { memo, useMemo } from 'react'
|
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
|
||||||
import StatusBar, {
|
|
||||||
type StatusBarSegment,
|
|
||||||
} from '@/app/workspace/[workspaceId]/logs/components/dashboard/status-bar'
|
|
||||||
import { useWorkflowRegistry } from '@/stores/workflows/registry/store'
|
|
||||||
|
|
||||||
export interface WorkflowExecutionItem {
|
|
||||||
workflowId: string
|
|
||||||
workflowName: string
|
|
||||||
segments: StatusBarSegment[]
|
|
||||||
overallSuccessRate: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WorkflowsList({
|
|
||||||
executions,
|
|
||||||
filteredExecutions,
|
|
||||||
expandedWorkflowId,
|
|
||||||
onToggleWorkflow,
|
|
||||||
selectedSegments,
|
|
||||||
onSegmentClick,
|
|
||||||
searchQuery,
|
|
||||||
segmentDurationMs,
|
|
||||||
}: {
|
|
||||||
executions: WorkflowExecutionItem[]
|
|
||||||
filteredExecutions: WorkflowExecutionItem[]
|
|
||||||
expandedWorkflowId: string | null
|
|
||||||
onToggleWorkflow: (workflowId: string) => void
|
|
||||||
selectedSegments: Record<string, number[]>
|
|
||||||
onSegmentClick: (
|
|
||||||
workflowId: string,
|
|
||||||
segmentIndex: number,
|
|
||||||
timestamp: string,
|
|
||||||
mode: 'single' | 'toggle' | 'range'
|
|
||||||
) => void
|
|
||||||
searchQuery: string
|
|
||||||
segmentDurationMs: number
|
|
||||||
}) {
|
|
||||||
const { workflows } = useWorkflowRegistry()
|
|
||||||
const segmentsCount = filteredExecutions[0]?.segments?.length || 120
|
|
||||||
const durationLabel = useMemo(() => {
|
|
||||||
const segMs = Math.max(1, Math.floor(segmentDurationMs || 0))
|
|
||||||
const days = Math.round(segMs / (24 * 60 * 60 * 1000))
|
|
||||||
if (days >= 1) return `${days} day${days !== 1 ? 's' : ''}`
|
|
||||||
const hours = Math.round(segMs / (60 * 60 * 1000))
|
|
||||||
if (hours >= 1) return `${hours} hour${hours !== 1 ? 's' : ''}`
|
|
||||||
const mins = Math.max(1, Math.round(segMs / (60 * 1000)))
|
|
||||||
return `${mins} minute${mins !== 1 ? 's' : ''}`
|
|
||||||
}, [segmentDurationMs])
|
|
||||||
|
|
||||||
// Date axis above the status bars intentionally removed for a cleaner, denser layout
|
|
||||||
|
|
||||||
function DynamicLegend() {
|
|
||||||
return (
|
|
||||||
<p className='mt-0.5 text-[11px] text-muted-foreground'>
|
|
||||||
Each cell ≈ {durationLabel} of the selected range. Click a cell to filter details.
|
|
||||||
</p>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className='overflow-hidden rounded-[11px] border bg-card shadow-sm'
|
|
||||||
style={{ height: '380px', display: 'flex', flexDirection: 'column' }}
|
|
||||||
>
|
|
||||||
<div className='flex-shrink-0 border-b bg-muted/30 px-4 py-2'>
|
|
||||||
<div className='flex items-center justify-between'>
|
|
||||||
<div>
|
|
||||||
<h3 className='font-[480] text-sm'>Workflows</h3>
|
|
||||||
<DynamicLegend />
|
|
||||||
</div>
|
|
||||||
<span className='text-muted-foreground text-xs'>
|
|
||||||
{filteredExecutions.length} workflow
|
|
||||||
{filteredExecutions.length !== 1 ? 's' : ''}
|
|
||||||
{searchQuery && ` (filtered from ${executions.length})`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Axis removed */}
|
|
||||||
<ScrollArea className='min-h-0 flex-1 overflow-auto'>
|
|
||||||
<div className='space-y-1 p-3'>
|
|
||||||
{filteredExecutions.length === 0 ? (
|
|
||||||
<div className='py-8 text-center text-muted-foreground text-sm'>
|
|
||||||
No workflows found matching "{searchQuery}"
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
filteredExecutions.map((workflow, idx) => {
|
|
||||||
const isSelected = expandedWorkflowId === workflow.workflowId
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={workflow.workflowId}
|
|
||||||
className={`flex cursor-pointer items-center gap-4 px-2 py-1.5 transition-colors ${
|
|
||||||
isSelected ? 'bg-accent/40' : 'hover:bg-accent/20'
|
|
||||||
}`}
|
|
||||||
onClick={() => onToggleWorkflow(workflow.workflowId)}
|
|
||||||
>
|
|
||||||
<div className='w-52 min-w-0 flex-shrink-0'>
|
|
||||||
<div className='flex items-center gap-2'>
|
|
||||||
<div
|
|
||||||
className='h-[14px] w-[14px] flex-shrink-0 rounded'
|
|
||||||
style={{
|
|
||||||
backgroundColor: workflows[workflow.workflowId]?.color || '#64748b',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<h3 className='truncate font-[460] text-sm dark:font-medium'>
|
|
||||||
{workflow.workflowName}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='flex-1'>
|
|
||||||
<StatusBar
|
|
||||||
segments={workflow.segments}
|
|
||||||
selectedSegmentIndices={selectedSegments[workflow.workflowId] || null}
|
|
||||||
onSegmentClick={onSegmentClick as any}
|
|
||||||
workflowId={workflow.workflowId}
|
|
||||||
segmentDurationMs={segmentDurationMs}
|
|
||||||
preferBelow={idx < 2}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='w-16 flex-shrink-0 text-right'>
|
|
||||||
<span className='font-[460] text-muted-foreground text-sm'>
|
|
||||||
{workflow.overallSuccessRate.toFixed(1)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default memo(WorkflowsList)
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
export default function FilterSection({
|
|
||||||
title,
|
|
||||||
content,
|
|
||||||
}: {
|
|
||||||
title: string
|
|
||||||
content?: React.ReactNode
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className='space-y-1'>
|
|
||||||
<div className='font-medium text-muted-foreground text-xs'>{title}</div>
|
|
||||||
<div>
|
|
||||||
{content || (
|
|
||||||
<div className='text-muted-foreground text-sm'>
|
|
||||||
Filter options for {title} will go here
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
import { useMemo, useState } from 'react'
|
|
||||||
import { Check, ChevronDown } from 'lucide-react'
|
|
||||||
import { useParams } from 'next/navigation'
|
|
||||||
import { Button } from '@/components/emcn'
|
|
||||||
import {
|
|
||||||
Command,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandInput,
|
|
||||||
CommandItem,
|
|
||||||
CommandList,
|
|
||||||
} from '@/components/ui/command'
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu'
|
|
||||||
import { createLogger } from '@/lib/logs/console/logger'
|
|
||||||
import {
|
|
||||||
commandListClass,
|
|
||||||
dropdownContentClass,
|
|
||||||
filterButtonClass,
|
|
||||||
folderDropdownListStyle,
|
|
||||||
} from '@/app/workspace/[workspaceId]/logs/components/filters/components/shared'
|
|
||||||
import { useFolders } from '@/hooks/queries/folders'
|
|
||||||
import { type FolderTreeNode, useFolderStore } from '@/stores/folders/store'
|
|
||||||
import { useFilterStore } from '@/stores/logs/filters/store'
|
|
||||||
|
|
||||||
const logger = createLogger('LogsFolderFilter')
|
|
||||||
|
|
||||||
interface FolderOption {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
color: string
|
|
||||||
path: string // For nested folders, show full path
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function FolderFilter() {
|
|
||||||
const { folderIds, toggleFolderId, setFolderIds } = useFilterStore()
|
|
||||||
const { getFolderTree } = useFolderStore()
|
|
||||||
const params = useParams()
|
|
||||||
const workspaceId = params.workspaceId as string
|
|
||||||
const [search, setSearch] = useState('')
|
|
||||||
const { isLoading: foldersLoading } = useFolders(workspaceId)
|
|
||||||
|
|
||||||
const folderTree = workspaceId ? getFolderTree(workspaceId) : []
|
|
||||||
|
|
||||||
const folders: FolderOption[] = useMemo(() => {
|
|
||||||
const flattenFolders = (nodes: FolderTreeNode[], parentPath = ''): FolderOption[] => {
|
|
||||||
const result: FolderOption[] = []
|
|
||||||
|
|
||||||
for (const node of nodes) {
|
|
||||||
const currentPath = parentPath ? `${parentPath} / ${node.name}` : node.name
|
|
||||||
result.push({
|
|
||||||
id: node.id,
|
|
||||||
name: node.name,
|
|
||||||
color: node.color || '#6B7280',
|
|
||||||
path: currentPath,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (node.children && node.children.length > 0) {
|
|
||||||
result.push(...flattenFolders(node.children, currentPath))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
return flattenFolders(folderTree)
|
|
||||||
}, [folderTree])
|
|
||||||
|
|
||||||
// Get display text for the dropdown button
|
|
||||||
const getSelectedFoldersText = () => {
|
|
||||||
if (folderIds.length === 0) return 'All folders'
|
|
||||||
if (folderIds.length === 1) {
|
|
||||||
const selected = folders.find((f) => f.id === folderIds[0])
|
|
||||||
return selected ? selected.name : 'All folders'
|
|
||||||
}
|
|
||||||
return `${folderIds.length} folders selected`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if a folder is selected
|
|
||||||
const isFolderSelected = (folderId: string) => {
|
|
||||||
return folderIds.includes(folderId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear all selections
|
|
||||||
const clearSelections = () => {
|
|
||||||
setFolderIds([])
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant='outline' className={filterButtonClass}>
|
|
||||||
{foldersLoading ? 'Loading folders...' : getSelectedFoldersText()}
|
|
||||||
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent
|
|
||||||
align='start'
|
|
||||||
side='bottom'
|
|
||||||
avoidCollisions={false}
|
|
||||||
sideOffset={4}
|
|
||||||
className={dropdownContentClass}
|
|
||||||
>
|
|
||||||
<Command>
|
|
||||||
<CommandInput placeholder='Search folders...' onValueChange={(v) => setSearch(v)} />
|
|
||||||
<CommandList className={commandListClass} style={folderDropdownListStyle}>
|
|
||||||
<CommandEmpty>
|
|
||||||
{foldersLoading ? 'Loading folders...' : 'No folders found.'}
|
|
||||||
</CommandEmpty>
|
|
||||||
<CommandGroup>
|
|
||||||
<CommandItem
|
|
||||||
value='all-folders'
|
|
||||||
onSelect={() => {
|
|
||||||
clearSelections()
|
|
||||||
}}
|
|
||||||
className='cursor-pointer'
|
|
||||||
>
|
|
||||||
<span>All folders</span>
|
|
||||||
{folderIds.length === 0 && (
|
|
||||||
<Check className='ml-auto h-4 w-4 text-muted-foreground' />
|
|
||||||
)}
|
|
||||||
</CommandItem>
|
|
||||||
{useMemo(() => {
|
|
||||||
const q = search.trim().toLowerCase()
|
|
||||||
const filtered = q
|
|
||||||
? folders.filter((f) => (f.path || f.name).toLowerCase().includes(q))
|
|
||||||
: folders
|
|
||||||
return filtered.map((folder) => (
|
|
||||||
<CommandItem
|
|
||||||
key={folder.id}
|
|
||||||
value={`${folder.path || folder.name}`}
|
|
||||||
onSelect={() => {
|
|
||||||
toggleFolderId(folder.id)
|
|
||||||
}}
|
|
||||||
className='cursor-pointer'
|
|
||||||
>
|
|
||||||
<div className='flex items-center'>
|
|
||||||
<span className='truncate' title={folder.path}>
|
|
||||||
{folder.path}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{isFolderSelected(folder.id) && (
|
|
||||||
<Check className='ml-auto h-4 w-4 text-muted-foreground' />
|
|
||||||
)}
|
|
||||||
</CommandItem>
|
|
||||||
))
|
|
||||||
}, [folders, search, folderIds])}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export { default as FilterSection } from './filter-section'
|
|
||||||
export { default as FolderFilter } from './folder'
|
|
||||||
export { default as Level } from './level'
|
|
||||||
export { default as Timeline } from './timeline'
|
|
||||||
export { default as Trigger } from './trigger'
|
|
||||||
export { default as Workflow } from './workflow'
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import { Check, ChevronDown } from 'lucide-react'
|
|
||||||
import { Button } from '@/components/emcn'
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu'
|
|
||||||
import { useFilterStore } from '@/stores/logs/filters/store'
|
|
||||||
import type { LogLevel } from '@/stores/logs/filters/types'
|
|
||||||
|
|
||||||
export default function Level() {
|
|
||||||
const { level, setLevel } = useFilterStore()
|
|
||||||
const specificLevels: { value: LogLevel; label: string; color: string }[] = [
|
|
||||||
{ value: 'error', label: 'Error', color: 'bg-destructive/100' },
|
|
||||||
{ value: 'info', label: 'Info', color: 'bg-muted-foreground/100' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const getDisplayLabel = () => {
|
|
||||||
if (level === 'all') return 'Any status'
|
|
||||||
const selected = specificLevels.find((l) => l.value === level)
|
|
||||||
return selected ? selected.label : 'Any status'
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant='outline'
|
|
||||||
className='h-8 w-full justify-between border-[#E5E5E5] bg-[var(--white)] font-normal text-sm dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
|
|
||||||
>
|
|
||||||
{getDisplayLabel()}
|
|
||||||
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent
|
|
||||||
align='start'
|
|
||||||
className='w-[180px] border-[#E5E5E5] bg-[var(--white)] shadow-xs dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
|
|
||||||
>
|
|
||||||
<DropdownMenuItem
|
|
||||||
key='all'
|
|
||||||
onSelect={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setLevel('all')
|
|
||||||
}}
|
|
||||||
className='flex cursor-pointer items-center justify-between px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
|
|
||||||
>
|
|
||||||
<span>Any status</span>
|
|
||||||
{level === 'all' && <Check className='h-4 w-4 text-muted-foreground' />}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
|
|
||||||
{specificLevels.map((levelItem) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={levelItem.value}
|
|
||||||
onSelect={(e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setLevel(levelItem.value)
|
|
||||||
}}
|
|
||||||
className='flex cursor-pointer items-center justify-between px-3 py-2 font-[380] text-card-foreground text-sm hover:bg-secondary/50 focus:bg-secondary/50'
|
|
||||||
>
|
|
||||||
<div className='flex items-center'>
|
|
||||||
<div className={`mr-2 h-2 w-2 rounded-full ${levelItem.color}`} />
|
|
||||||
{levelItem.label}
|
|
||||||
</div>
|
|
||||||
{level === levelItem.value && <Check className='h-4 w-4 text-muted-foreground' />}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
export const filterButtonClass =
|
|
||||||
'w-full justify-between rounded-[10px] border-[#E5E5E5] bg-[var(--white)] font-normal text-sm dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
|
|
||||||
|
|
||||||
export const dropdownContentClass =
|
|
||||||
'w-[200px] rounded-lg border-[#E5E5E5] bg-[var(--white)] p-0 shadow-xs dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
|
|
||||||
|
|
||||||
export const commandListClass = 'overflow-y-auto overflow-x-hidden'
|
|
||||||
|
|
||||||
export const workflowDropdownListStyle = {
|
|
||||||
maxHeight: '14rem',
|
|
||||||
overflowY: 'auto',
|
|
||||||
overflowX: 'hidden',
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export const folderDropdownListStyle = {
|
|
||||||
maxHeight: '10rem',
|
|
||||||
overflowY: 'auto',
|
|
||||||
overflowX: 'hidden',
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export const triggerDropdownListStyle = {
|
|
||||||
maxHeight: '7.5rem',
|
|
||||||
overflowY: 'auto',
|
|
||||||
overflowX: 'hidden',
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export const timelineDropdownListStyle = {
|
|
||||||
maxHeight: '9rem',
|
|
||||||
overflowY: 'auto',
|
|
||||||
overflowX: 'hidden',
|
|
||||||
} as const
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { ChevronDown } from 'lucide-react'
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverItem,
|
|
||||||
PopoverScrollArea,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from '@/components/emcn'
|
|
||||||
import { filterButtonClass } from '@/app/workspace/[workspaceId]/logs/components/filters/components/shared'
|
|
||||||
import { useFilterStore } from '@/stores/logs/filters/store'
|
|
||||||
import type { TimeRange } from '@/stores/logs/filters/types'
|
|
||||||
|
|
||||||
type TimelineProps = {
|
|
||||||
variant?: 'default' | 'header'
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Timeline({ variant = 'default' }: TimelineProps = {}) {
|
|
||||||
const { timeRange, setTimeRange } = useFilterStore()
|
|
||||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false)
|
|
||||||
|
|
||||||
const specificTimeRanges: TimeRange[] = [
|
|
||||||
'Past 30 minutes',
|
|
||||||
'Past hour',
|
|
||||||
'Past 6 hours',
|
|
||||||
'Past 12 hours',
|
|
||||||
'Past 24 hours',
|
|
||||||
'Past 3 days',
|
|
||||||
'Past 7 days',
|
|
||||||
'Past 14 days',
|
|
||||||
'Past 30 days',
|
|
||||||
]
|
|
||||||
|
|
||||||
const handleTimeRangeSelect = (range: TimeRange) => {
|
|
||||||
setTimeRange(range)
|
|
||||||
setIsPopoverOpen(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button variant='outline' className={filterButtonClass}>
|
|
||||||
{timeRange}
|
|
||||||
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent
|
|
||||||
align={variant === 'header' ? 'end' : 'start'}
|
|
||||||
side='bottom'
|
|
||||||
sideOffset={4}
|
|
||||||
maxHeight={144}
|
|
||||||
>
|
|
||||||
<PopoverScrollArea>
|
|
||||||
<PopoverItem
|
|
||||||
active={timeRange === 'All time'}
|
|
||||||
showCheck
|
|
||||||
onClick={() => handleTimeRangeSelect('All time')}
|
|
||||||
>
|
|
||||||
All time
|
|
||||||
</PopoverItem>
|
|
||||||
|
|
||||||
{/* Separator */}
|
|
||||||
<div className='my-[2px] h-px bg-[var(--surface-11)]' />
|
|
||||||
|
|
||||||
{specificTimeRanges.map((range) => (
|
|
||||||
<PopoverItem
|
|
||||||
key={range}
|
|
||||||
active={timeRange === range}
|
|
||||||
showCheck
|
|
||||||
onClick={() => handleTimeRangeSelect(range)}
|
|
||||||
>
|
|
||||||
{range}
|
|
||||||
</PopoverItem>
|
|
||||||
))}
|
|
||||||
</PopoverScrollArea>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
import { useMemo, useState } from 'react'
|
|
||||||
import { Check, ChevronDown } from 'lucide-react'
|
|
||||||
import { Button } from '@/components/emcn'
|
|
||||||
import {
|
|
||||||
Command,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandInput,
|
|
||||||
CommandItem,
|
|
||||||
CommandList,
|
|
||||||
} from '@/components/ui/command'
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu'
|
|
||||||
import { getTriggerOptions } from '@/lib/logs/get-trigger-options'
|
|
||||||
import {
|
|
||||||
commandListClass,
|
|
||||||
dropdownContentClass,
|
|
||||||
filterButtonClass,
|
|
||||||
triggerDropdownListStyle,
|
|
||||||
} from '@/app/workspace/[workspaceId]/logs/components/filters/components/shared'
|
|
||||||
import { useFilterStore } from '@/stores/logs/filters/store'
|
|
||||||
import type { TriggerType } from '@/stores/logs/filters/types'
|
|
||||||
|
|
||||||
export default function Trigger() {
|
|
||||||
const { triggers, toggleTrigger, setTriggers } = useFilterStore()
|
|
||||||
const [search, setSearch] = useState('')
|
|
||||||
|
|
||||||
const triggerOptions = useMemo(() => getTriggerOptions(), [])
|
|
||||||
|
|
||||||
const getSelectedTriggersText = () => {
|
|
||||||
if (triggers.length === 0) return 'All triggers'
|
|
||||||
if (triggers.length === 1) {
|
|
||||||
const selected = triggerOptions.find((t) => t.value === triggers[0])
|
|
||||||
return selected ? selected.label : 'All triggers'
|
|
||||||
}
|
|
||||||
return `${triggers.length} triggers selected`
|
|
||||||
}
|
|
||||||
|
|
||||||
const isTriggerSelected = (trigger: TriggerType) => {
|
|
||||||
return triggers.includes(trigger)
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearSelections = () => {
|
|
||||||
setTriggers([])
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant='outline' className={filterButtonClass}>
|
|
||||||
{getSelectedTriggersText()}
|
|
||||||
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent
|
|
||||||
align='start'
|
|
||||||
side='bottom'
|
|
||||||
avoidCollisions={false}
|
|
||||||
sideOffset={4}
|
|
||||||
className={dropdownContentClass}
|
|
||||||
>
|
|
||||||
<Command>
|
|
||||||
<CommandInput placeholder='Search triggers...' onValueChange={(v) => setSearch(v)} />
|
|
||||||
<CommandList className={commandListClass} style={triggerDropdownListStyle}>
|
|
||||||
<CommandEmpty>No triggers found.</CommandEmpty>
|
|
||||||
<CommandGroup>
|
|
||||||
<CommandItem
|
|
||||||
value='all-triggers'
|
|
||||||
onSelect={() => clearSelections()}
|
|
||||||
className='cursor-pointer'
|
|
||||||
>
|
|
||||||
<span>All triggers</span>
|
|
||||||
{triggers.length === 0 && (
|
|
||||||
<Check className='ml-auto h-4 w-4 text-muted-foreground' />
|
|
||||||
)}
|
|
||||||
</CommandItem>
|
|
||||||
{useMemo(() => {
|
|
||||||
const q = search.trim().toLowerCase()
|
|
||||||
const filtered = q
|
|
||||||
? triggerOptions.filter((t) => t.label.toLowerCase().includes(q))
|
|
||||||
: triggerOptions
|
|
||||||
return filtered.map((triggerItem) => (
|
|
||||||
<CommandItem
|
|
||||||
key={triggerItem.value}
|
|
||||||
value={triggerItem.label}
|
|
||||||
onSelect={() => toggleTrigger(triggerItem.value)}
|
|
||||||
className='cursor-pointer'
|
|
||||||
>
|
|
||||||
<div className='flex items-center'>
|
|
||||||
{triggerItem.color && (
|
|
||||||
<div
|
|
||||||
className='mr-2 h-2 w-2 rounded-full'
|
|
||||||
style={{ backgroundColor: triggerItem.color }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{triggerItem.label}
|
|
||||||
</div>
|
|
||||||
{isTriggerSelected(triggerItem.value) && (
|
|
||||||
<Check className='ml-auto h-4 w-4 text-muted-foreground' />
|
|
||||||
)}
|
|
||||||
</CommandItem>
|
|
||||||
))
|
|
||||||
}, [search, triggers])}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
|
||||||
import { Check, ChevronDown } from 'lucide-react'
|
|
||||||
import { useParams } from 'next/navigation'
|
|
||||||
import { Button } from '@/components/emcn'
|
|
||||||
import {
|
|
||||||
Command,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandInput,
|
|
||||||
CommandItem,
|
|
||||||
CommandList,
|
|
||||||
} from '@/components/ui/command'
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu'
|
|
||||||
import { createLogger } from '@/lib/logs/console/logger'
|
|
||||||
import {
|
|
||||||
commandListClass,
|
|
||||||
dropdownContentClass,
|
|
||||||
filterButtonClass,
|
|
||||||
workflowDropdownListStyle,
|
|
||||||
} from '@/app/workspace/[workspaceId]/logs/components/filters/components/shared'
|
|
||||||
import { useFilterStore } from '@/stores/logs/filters/store'
|
|
||||||
|
|
||||||
const logger = createLogger('LogsWorkflowFilter')
|
|
||||||
|
|
||||||
interface WorkflowOption {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
color: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Workflow() {
|
|
||||||
const { workflowIds, toggleWorkflowId, setWorkflowIds, folderIds } = useFilterStore()
|
|
||||||
const params = useParams()
|
|
||||||
const workspaceId = params?.workspaceId as string | undefined
|
|
||||||
const [workflows, setWorkflows] = useState<WorkflowOption[]>([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [search, setSearch] = useState('')
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchWorkflows = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true)
|
|
||||||
const query = workspaceId ? `?workspaceId=${encodeURIComponent(workspaceId)}` : ''
|
|
||||||
const response = await fetch(`/api/workflows${query}`)
|
|
||||||
if (response.ok) {
|
|
||||||
const { data } = await response.json()
|
|
||||||
const scoped = Array.isArray(data)
|
|
||||||
? folderIds.length > 0
|
|
||||||
? data.filter((w: any) => (w.folderId ? folderIds.includes(w.folderId) : false))
|
|
||||||
: data
|
|
||||||
: []
|
|
||||||
const workflowOptions: WorkflowOption[] = scoped.map((workflow: any) => ({
|
|
||||||
id: workflow.id,
|
|
||||||
name: workflow.name,
|
|
||||||
color: workflow.color || '#3972F6',
|
|
||||||
}))
|
|
||||||
setWorkflows(workflowOptions)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Failed to fetch workflows', { error })
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchWorkflows()
|
|
||||||
}, [workspaceId, folderIds])
|
|
||||||
|
|
||||||
const getSelectedWorkflowsText = () => {
|
|
||||||
if (workflowIds.length === 0) return 'All workflows'
|
|
||||||
if (workflowIds.length === 1) {
|
|
||||||
const selected = workflows.find((w) => w.id === workflowIds[0])
|
|
||||||
return selected ? selected.name : 'All workflows'
|
|
||||||
}
|
|
||||||
return `${workflowIds.length} workflows selected`
|
|
||||||
}
|
|
||||||
|
|
||||||
const isWorkflowSelected = (workflowId: string) => {
|
|
||||||
return workflowIds.includes(workflowId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearSelections = () => {
|
|
||||||
setWorkflowIds([])
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant='outline' className={filterButtonClass}>
|
|
||||||
{loading ? 'Loading workflows...' : getSelectedWorkflowsText()}
|
|
||||||
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent
|
|
||||||
align='start'
|
|
||||||
side='bottom'
|
|
||||||
avoidCollisions={false}
|
|
||||||
sideOffset={4}
|
|
||||||
className={dropdownContentClass}
|
|
||||||
>
|
|
||||||
<Command>
|
|
||||||
<CommandInput placeholder='Search workflows...' onValueChange={(v) => setSearch(v)} />
|
|
||||||
<CommandList className={commandListClass} style={workflowDropdownListStyle}>
|
|
||||||
<CommandEmpty>{loading ? 'Loading workflows...' : 'No workflows found.'}</CommandEmpty>
|
|
||||||
<CommandGroup>
|
|
||||||
<CommandItem
|
|
||||||
value='all-workflows'
|
|
||||||
onSelect={() => {
|
|
||||||
clearSelections()
|
|
||||||
}}
|
|
||||||
className='cursor-pointer'
|
|
||||||
>
|
|
||||||
<span>All workflows</span>
|
|
||||||
{workflowIds.length === 0 && (
|
|
||||||
<Check className='ml-auto h-4 w-4 text-muted-foreground' />
|
|
||||||
)}
|
|
||||||
</CommandItem>
|
|
||||||
{useMemo(() => {
|
|
||||||
const q = search.trim().toLowerCase()
|
|
||||||
const filtered = q
|
|
||||||
? workflows.filter((w) => w.name.toLowerCase().includes(q))
|
|
||||||
: workflows
|
|
||||||
return filtered.map((workflow) => (
|
|
||||||
<CommandItem
|
|
||||||
key={workflow.id}
|
|
||||||
value={`${workflow.name}`}
|
|
||||||
onSelect={() => {
|
|
||||||
toggleWorkflowId(workflow.id)
|
|
||||||
}}
|
|
||||||
className='cursor-pointer'
|
|
||||||
>
|
|
||||||
<div className='flex items-center'>
|
|
||||||
<div
|
|
||||||
className='mr-2 h-2 w-2 rounded-full'
|
|
||||||
style={{ backgroundColor: workflow.color }}
|
|
||||||
/>
|
|
||||||
{workflow.name}
|
|
||||||
</div>
|
|
||||||
{isWorkflowSelected(workflow.id) && (
|
|
||||||
<Check className='ml-auto h-4 w-4 text-muted-foreground' />
|
|
||||||
)}
|
|
||||||
</CommandItem>
|
|
||||||
))
|
|
||||||
}, [workflows, search, workflowIds])}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { TimerOff } from 'lucide-react'
|
|
||||||
import { Button } from '@/components/emcn'
|
|
||||||
import { getSubscriptionStatus } from '@/lib/billing/client/utils'
|
|
||||||
import { isProd } from '@/lib/core/config/environment'
|
|
||||||
import {
|
|
||||||
FilterSection,
|
|
||||||
FolderFilter,
|
|
||||||
Level,
|
|
||||||
Timeline,
|
|
||||||
Trigger,
|
|
||||||
Workflow,
|
|
||||||
} from '@/app/workspace/[workspaceId]/logs/components/filters/components'
|
|
||||||
import { useSubscriptionData } from '@/hooks/queries/subscription'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Filters component for logs page - includes timeline and other filter options
|
|
||||||
*/
|
|
||||||
export function Filters() {
|
|
||||||
const { data: subscriptionData, isLoading } = useSubscriptionData()
|
|
||||||
const subscription = getSubscriptionStatus(subscriptionData?.data)
|
|
||||||
const isPaid = subscription.isPaid
|
|
||||||
|
|
||||||
const handleUpgradeClick = (e: React.MouseEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
const event = new CustomEvent('open-settings', {
|
|
||||||
detail: { tab: 'subscription' },
|
|
||||||
})
|
|
||||||
window.dispatchEvent(event)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='h-full w-60 overflow-auto border-r p-4'>
|
|
||||||
{/* Show retention policy for free users in production only */}
|
|
||||||
{!isLoading && !isPaid && isProd && (
|
|
||||||
<div className='mb-4 overflow-hidden border border-border'>
|
|
||||||
<div className='flex items-center gap-2 border-b bg-background p-3'>
|
|
||||||
<TimerOff className='h-4 w-4 text-muted-foreground' />
|
|
||||||
<span className='font-medium text-sm'>Log Retention Policy</span>
|
|
||||||
</div>
|
|
||||||
<div className='p-3'>
|
|
||||||
<p className='text-muted-foreground text-xs'>
|
|
||||||
Logs are automatically deleted after 7 days.
|
|
||||||
</p>
|
|
||||||
<div className='mt-2.5'>
|
|
||||||
<Button
|
|
||||||
variant='default'
|
|
||||||
className='h-8 w-full px-3 text-xs'
|
|
||||||
onClick={handleUpgradeClick}
|
|
||||||
>
|
|
||||||
Upgrade Plan
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<h2 className='mb-4 pl-2 font-medium text-sm'>Filters</h2>
|
|
||||||
|
|
||||||
{/* Level Filter */}
|
|
||||||
<FilterSection title='Level' content={<Level />} />
|
|
||||||
|
|
||||||
{/* Workflow Filter */}
|
|
||||||
<FilterSection title='Workflow' content={<Workflow />} />
|
|
||||||
|
|
||||||
{/* Folder Filter */}
|
|
||||||
<FilterSection title='Folder' content={<FolderFilter />} />
|
|
||||||
|
|
||||||
{/* Trigger Filter */}
|
|
||||||
<FilterSection title='Trigger' content={<Trigger />} />
|
|
||||||
|
|
||||||
{/* Timeline Filter */}
|
|
||||||
<FilterSection title='Timeline' content={<Timeline />} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { Maximize2, Minimize2, X } from 'lucide-react'
|
|
||||||
import { Button } from '@/components/emcn'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
|
||||||
import { FrozenCanvas } from '@/app/workspace/[workspaceId]/logs/components/frozen-canvas/frozen-canvas'
|
|
||||||
|
|
||||||
interface FrozenCanvasModalProps {
|
|
||||||
executionId: string
|
|
||||||
workflowName?: string
|
|
||||||
trigger?: string
|
|
||||||
traceSpans?: any[] // TraceSpans data from log metadata
|
|
||||||
isOpen: boolean
|
|
||||||
onClose: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FrozenCanvasModal({
|
|
||||||
executionId,
|
|
||||||
workflowName,
|
|
||||||
trigger,
|
|
||||||
traceSpans,
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
}: FrozenCanvasModalProps) {
|
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
|
||||||
|
|
||||||
const toggleFullscreen = () => {
|
|
||||||
setIsFullscreen(!isFullscreen)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
|
||||||
<DialogContent
|
|
||||||
className={cn(
|
|
||||||
'flex flex-col gap-0 p-0',
|
|
||||||
isFullscreen
|
|
||||||
? 'h-[100vh] max-h-[100vh] w-[100vw] max-w-[100vw]'
|
|
||||||
: 'h-[90vh] max-h-[90vh] overflow-hidden sm:max-w-[1100px]'
|
|
||||||
)}
|
|
||||||
hideCloseButton={true}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<DialogHeader className='flex flex-row items-center justify-between border-b bg-[var(--surface-1)] p-[16px] dark:border-[var(--border)] dark:bg-[var(--surface-1)]'>
|
|
||||||
<div className='flex items-center gap-[12px]'>
|
|
||||||
<div>
|
|
||||||
<DialogTitle className='font-semibold text-[15px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
|
|
||||||
Logged Workflow State
|
|
||||||
</DialogTitle>
|
|
||||||
<div className='mt-[4px] flex items-center gap-[8px]'>
|
|
||||||
{workflowName && (
|
|
||||||
<span className='text-[13px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
|
|
||||||
{workflowName}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{trigger && (
|
|
||||||
<Badge variant='secondary' className='text-[12px]'>
|
|
||||||
{trigger}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
<span className='font-mono text-[12px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
|
|
||||||
{executionId.slice(0, 8)}...
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='flex items-center gap-[8px]'>
|
|
||||||
<Button variant='ghost' onClick={toggleFullscreen} className='h-[32px] w-[32px] p-0'>
|
|
||||||
{isFullscreen ? (
|
|
||||||
<Minimize2 className='h-[14px] w-[14px]' />
|
|
||||||
) : (
|
|
||||||
<Maximize2 className='h-[14px] w-[14px]' />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<Button variant='ghost' onClick={onClose} className='h-[32px] w-[32px] p-0'>
|
|
||||||
<X className='h-[14px] w-[14px]' />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
{/* Canvas Container */}
|
|
||||||
<div className='min-h-0 flex-1'>
|
|
||||||
<FrozenCanvas
|
|
||||||
executionId={executionId}
|
|
||||||
traceSpans={traceSpans}
|
|
||||||
height='100%'
|
|
||||||
width='100%'
|
|
||||||
// Ensure preview leaves padding at edges so nodes don't touch header
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer with instructions */}
|
|
||||||
<div className='border-t bg-[var(--surface-1)] px-[24px] py-[12px] dark:border-[var(--border)] dark:bg-[var(--surface-1)]'>
|
|
||||||
<div className='text-[13px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
|
|
||||||
Click on blocks to see their input and output data at execution time. This canvas shows
|
|
||||||
the exact state of the workflow when this execution was captured.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export { Dashboard } from './dashboard'
|
||||||
|
export { LogDetails } from './log-details'
|
||||||
|
export { FileCards } from './log-details/components/file-download'
|
||||||
|
export { FrozenCanvas } from './log-details/components/frozen-canvas'
|
||||||
|
export { TraceSpans } from './log-details/components/trace-spans'
|
||||||
|
export {
|
||||||
|
AutocompleteSearch,
|
||||||
|
Controls,
|
||||||
|
LogsToolbar,
|
||||||
|
NotificationSettings,
|
||||||
|
} from './logs-toolbar'
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { ArrowDown, Loader2 } from 'lucide-react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { Button } from '@/components/emcn'
|
||||||
|
import { createLogger } from '@/lib/logs/console/logger'
|
||||||
|
import { extractWorkspaceIdFromExecutionKey, getViewerUrl } from '@/lib/uploads/utils/file-utils'
|
||||||
|
|
||||||
|
const logger = createLogger('FileCards')
|
||||||
|
|
||||||
|
interface FileData {
|
||||||
|
id?: string
|
||||||
|
name: string
|
||||||
|
size: number
|
||||||
|
type: string
|
||||||
|
key: string
|
||||||
|
url: string
|
||||||
|
uploadedAt: string
|
||||||
|
expiresAt: string
|
||||||
|
storageProvider?: 's3' | 'blob' | 'local'
|
||||||
|
bucketName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileCardsProps {
|
||||||
|
files: FileData[]
|
||||||
|
isExecutionFile?: boolean
|
||||||
|
workspaceId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileCardProps {
|
||||||
|
file: FileData
|
||||||
|
isExecutionFile?: boolean
|
||||||
|
workspaceId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats file size to human readable format
|
||||||
|
*/
|
||||||
|
function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B'
|
||||||
|
const k = 1024
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
return `${Number.parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Individual file card component
|
||||||
|
*/
|
||||||
|
function FileCard({ file, isExecutionFile = false, workspaceId }: FileCardProps) {
|
||||||
|
const [isDownloading, setIsDownloading] = useState(false)
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
if (isDownloading) return
|
||||||
|
|
||||||
|
setIsDownloading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info(`Initiating download for file: ${file.name}`)
|
||||||
|
|
||||||
|
if (file.key.startsWith('url/')) {
|
||||||
|
if (file.url) {
|
||||||
|
window.open(file.url, '_blank')
|
||||||
|
logger.info(`Opened URL-type file directly: ${file.url}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw new Error('URL is required for URL-type files')
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolvedWorkspaceId = workspaceId
|
||||||
|
if (!resolvedWorkspaceId && isExecutionFile) {
|
||||||
|
resolvedWorkspaceId = extractWorkspaceIdFromExecutionKey(file.key) || undefined
|
||||||
|
} else if (!resolvedWorkspaceId) {
|
||||||
|
const segments = file.key.split('/')
|
||||||
|
if (segments.length >= 2 && /^[a-f0-9-]{36}$/.test(segments[0])) {
|
||||||
|
resolvedWorkspaceId = segments[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isExecutionFile) {
|
||||||
|
const serveUrl =
|
||||||
|
file.url || `/api/files/serve/${encodeURIComponent(file.key)}?context=execution`
|
||||||
|
window.open(serveUrl, '_blank')
|
||||||
|
logger.info(`Opened execution file serve URL: ${serveUrl}`)
|
||||||
|
} else {
|
||||||
|
const viewerUrl = resolvedWorkspaceId ? getViewerUrl(file.key, resolvedWorkspaceId) : null
|
||||||
|
|
||||||
|
if (viewerUrl) {
|
||||||
|
router.push(viewerUrl)
|
||||||
|
logger.info(`Navigated to viewer URL: ${viewerUrl}`)
|
||||||
|
} else {
|
||||||
|
logger.warn(
|
||||||
|
`Could not construct viewer URL for file: ${file.name}, falling back to serve URL`
|
||||||
|
)
|
||||||
|
const serveUrl =
|
||||||
|
file.url || `/api/files/serve/${encodeURIComponent(file.key)}?context=workspace`
|
||||||
|
window.open(serveUrl, '_blank')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to download file ${file.name}:`, error)
|
||||||
|
if (file.url) {
|
||||||
|
window.open(file.url, '_blank')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsDownloading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex flex-col gap-[8px] rounded-[6px] bg-[var(--surface-1)] px-[10px] py-[8px]'>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<div className='flex items-center gap-[8px]'>
|
||||||
|
<span className='truncate font-medium text-[12px] text-[var(--text-secondary)]'>
|
||||||
|
{file.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||||
|
{formatFileSize(file.size)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<span className='font-medium text-[11px] text-[var(--text-subtle)]'>{file.type}</span>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
className='!h-[20px] !px-[6px] !py-0 text-[11px]'
|
||||||
|
onClick={handleDownload}
|
||||||
|
disabled={isDownloading}
|
||||||
|
>
|
||||||
|
{isDownloading ? (
|
||||||
|
<Loader2 className='mr-[4px] h-[10px] w-[10px] animate-spin' />
|
||||||
|
) : (
|
||||||
|
<ArrowDown className='mr-[4px] h-[10px] w-[10px]' />
|
||||||
|
)}
|
||||||
|
{isDownloading ? 'Opening...' : 'Download'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Container component for displaying workflow execution files.
|
||||||
|
* Each file is displayed as a separate card with consistent styling.
|
||||||
|
*/
|
||||||
|
export function FileCards({ files, isExecutionFile = false, workspaceId }: FileCardsProps) {
|
||||||
|
if (!files || files.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex w-full flex-col gap-[6px] rounded-[6px] bg-[var(--surface-2)] px-[10px] py-[8px]'>
|
||||||
|
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||||
|
Files ({files.length})
|
||||||
|
</span>
|
||||||
|
<div className='flex flex-col gap-[8px]'>
|
||||||
|
{files.map((file, index) => (
|
||||||
|
<FileCard
|
||||||
|
key={file.id || `file-${index}`}
|
||||||
|
file={file}
|
||||||
|
isExecutionFile={isExecutionFile}
|
||||||
|
workspaceId={workspaceId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single file download button (legacy export for backwards compatibility)
|
||||||
|
*/
|
||||||
|
export function FileDownload({
|
||||||
|
file,
|
||||||
|
isExecutionFile = false,
|
||||||
|
className,
|
||||||
|
workspaceId,
|
||||||
|
}: {
|
||||||
|
file: FileData
|
||||||
|
isExecutionFile?: boolean
|
||||||
|
className?: string
|
||||||
|
workspaceId?: string
|
||||||
|
}) {
|
||||||
|
const [isDownloading, setIsDownloading] = useState(false)
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
if (isDownloading) return
|
||||||
|
|
||||||
|
setIsDownloading(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info(`Initiating download for file: ${file.name}`)
|
||||||
|
|
||||||
|
if (file.key.startsWith('url/')) {
|
||||||
|
if (file.url) {
|
||||||
|
window.open(file.url, '_blank')
|
||||||
|
logger.info(`Opened URL-type file directly: ${file.url}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw new Error('URL is required for URL-type files')
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolvedWorkspaceId = workspaceId
|
||||||
|
if (!resolvedWorkspaceId && isExecutionFile) {
|
||||||
|
resolvedWorkspaceId = extractWorkspaceIdFromExecutionKey(file.key) || undefined
|
||||||
|
} else if (!resolvedWorkspaceId) {
|
||||||
|
const segments = file.key.split('/')
|
||||||
|
if (segments.length >= 2 && /^[a-f0-9-]{36}$/.test(segments[0])) {
|
||||||
|
resolvedWorkspaceId = segments[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isExecutionFile) {
|
||||||
|
const serveUrl =
|
||||||
|
file.url || `/api/files/serve/${encodeURIComponent(file.key)}?context=execution`
|
||||||
|
window.open(serveUrl, '_blank')
|
||||||
|
logger.info(`Opened execution file serve URL: ${serveUrl}`)
|
||||||
|
} else {
|
||||||
|
const viewerUrl = resolvedWorkspaceId ? getViewerUrl(file.key, resolvedWorkspaceId) : null
|
||||||
|
|
||||||
|
if (viewerUrl) {
|
||||||
|
router.push(viewerUrl)
|
||||||
|
logger.info(`Navigated to viewer URL: ${viewerUrl}`)
|
||||||
|
} else {
|
||||||
|
logger.warn(
|
||||||
|
`Could not construct viewer URL for file: ${file.name}, falling back to serve URL`
|
||||||
|
)
|
||||||
|
const serveUrl =
|
||||||
|
file.url || `/api/files/serve/${encodeURIComponent(file.key)}?context=workspace`
|
||||||
|
window.open(serveUrl, '_blank')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to download file ${file.name}:`, error)
|
||||||
|
if (file.url) {
|
||||||
|
window.open(file.url, '_blank')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsDownloading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
className={`h-7 px-2 text-xs ${className}`}
|
||||||
|
onClick={handleDownload}
|
||||||
|
disabled={isDownloading}
|
||||||
|
>
|
||||||
|
{isDownloading ? (
|
||||||
|
<Loader2 className='h-3 w-3 animate-spin' />
|
||||||
|
) : (
|
||||||
|
<ArrowDown className='h-[14px] w-[14px]' />
|
||||||
|
)}
|
||||||
|
{isDownloading ? 'Downloading...' : 'Download'}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FileCards
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default, FileCards, FileDownload } from './file-download'
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
X,
|
X,
|
||||||
Zap,
|
Zap,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
import { Modal, ModalBody, ModalContent, ModalHeader } from '@/components/emcn'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { redactApiKeys } from '@/lib/core/security/redaction'
|
import { redactApiKeys } from '@/lib/core/security/redaction'
|
||||||
@@ -35,61 +36,59 @@ function ExpandableDataSection({ title, data }: { title: string; data: any }) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<div className='mb-[8px] flex items-center justify-between'>
|
<div className='mb-[6px] flex items-center justify-between'>
|
||||||
<h4 className='font-medium text-[13px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
|
<h4 className='font-medium text-[13px] text-[var(--text-primary)]'>{title}</h4>
|
||||||
{title}
|
|
||||||
</h4>
|
|
||||||
<div className='flex items-center gap-[4px]'>
|
<div className='flex items-center gap-[4px]'>
|
||||||
{isLargeData && (
|
{isLargeData && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsModalOpen(true)}
|
onClick={() => setIsModalOpen(true)}
|
||||||
className='p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--border)] hover:text-[var(--text-primary)] dark:text-[var(--text-secondary)] dark:hover:bg-[var(--border)] dark:hover:text-[var(--text-primary)]'
|
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)]'
|
||||||
title='Expand in modal'
|
title='Expand in modal'
|
||||||
|
type='button'
|
||||||
>
|
>
|
||||||
<Maximize2 className='h-[12px] w-[12px]' />
|
<Maximize2 className='h-[14px] w-[14px]' />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsExpanded(!isExpanded)}
|
onClick={() => setIsExpanded(!isExpanded)}
|
||||||
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--border)] hover:text-[var(--text-primary)] dark:text-[var(--text-secondary)] dark:hover:bg-[var(--border)] dark:hover:text-[var(--text-primary)]'
|
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)]'
|
||||||
|
type='button'
|
||||||
>
|
>
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
<ChevronUp className='h-[12px] w-[12px]' />
|
<ChevronUp className='h-[14px] w-[14px]' />
|
||||||
) : (
|
) : (
|
||||||
<ChevronDown className='h-[12px] w-[12px]' />
|
<ChevronDown className='h-[14px] w-[14px]' />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'overflow-y-auto bg-[var(--surface-5)] p-[12px] font-mono text-[12px] transition-all duration-200',
|
'overflow-y-auto rounded-[4px] border border-[var(--border)] bg-[var(--surface-3)] p-[12px] font-mono text-[12px] transition-all duration-200',
|
||||||
isExpanded ? 'max-h-96' : 'max-h-32'
|
isExpanded ? 'max-h-96' : 'max-h-32'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<pre className='whitespace-pre-wrap break-words text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
|
<pre className='whitespace-pre-wrap break-words text-[var(--text-primary)]'>
|
||||||
{jsonString}
|
{jsonString}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Modal for large data */}
|
|
||||||
{isModalOpen && (
|
{isModalOpen && (
|
||||||
<div className='fixed inset-0 z-[200] flex items-center justify-center bg-black/50'>
|
<div className='fixed inset-0 z-[200] flex items-center justify-center bg-black/50'>
|
||||||
<div className='mx-[16px] h-[80vh] w-full max-w-4xl border bg-[var(--surface-1)] shadow-lg dark:border-[var(--border)] dark:bg-[var(--surface-1)]'>
|
<div className='mx-[16px] flex h-[80vh] w-full max-w-4xl flex-col overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)] shadow-lg'>
|
||||||
<div className='flex items-center justify-between border-b p-[16px] dark:border-[var(--border)]'>
|
<div className='flex items-center justify-between border-[var(--border)] border-b p-[16px]'>
|
||||||
<h3 className='font-medium text-[15px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
|
<h3 className='font-medium text-[15px] text-[var(--text-primary)]'>{title}</h3>
|
||||||
{title}
|
|
||||||
</h3>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsModalOpen(false)}
|
onClick={() => setIsModalOpen(false)}
|
||||||
className='p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--border)] hover:text-[var(--text-primary)] dark:text-[var(--text-secondary)] dark:hover:bg-[var(--border)] dark:hover:text-[var(--text-primary)]'
|
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)]'
|
||||||
|
type='button'
|
||||||
>
|
>
|
||||||
<X className='h-[14px] w-[14px]' />
|
<X className='h-[16px] w-[16px]' />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className='h-[calc(80vh-4rem)] overflow-auto p-[16px]'>
|
<div className='flex-1 overflow-auto p-[16px]'>
|
||||||
<pre className='whitespace-pre-wrap break-words font-mono text-[13px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
|
<pre className='whitespace-pre-wrap break-words font-mono text-[13px] text-[var(--text-primary)]'>
|
||||||
{jsonString}
|
{jsonString}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
@@ -170,56 +169,45 @@ function PinnedLogs({
|
|||||||
workflowState: any
|
workflowState: any
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}) {
|
}) {
|
||||||
// ALL HOOKS MUST BE CALLED BEFORE ANY CONDITIONAL RETURNS
|
|
||||||
const [currentIterationIndex, setCurrentIterationIndex] = useState(0)
|
const [currentIterationIndex, setCurrentIterationIndex] = useState(0)
|
||||||
|
|
||||||
// Reset iteration index when execution data changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentIterationIndex(0)
|
setCurrentIterationIndex(0)
|
||||||
}, [executionData])
|
}, [executionData])
|
||||||
|
|
||||||
// Handle case where block has no execution data (e.g., failed workflow)
|
|
||||||
if (!executionData) {
|
if (!executionData) {
|
||||||
const blockInfo = workflowState?.blocks?.[blockId]
|
const blockInfo = workflowState?.blocks?.[blockId]
|
||||||
const formatted = {
|
const formatted = {
|
||||||
blockName: blockInfo?.name || 'Unknown Block',
|
blockName: blockInfo?.name || 'Unknown Block',
|
||||||
blockType: blockInfo?.type || 'unknown',
|
blockType: blockInfo?.type || 'unknown',
|
||||||
status: 'not_executed',
|
status: 'not_executed',
|
||||||
duration: 'N/A',
|
|
||||||
input: null,
|
|
||||||
output: null,
|
|
||||||
errorMessage: null,
|
|
||||||
errorStackTrace: null,
|
|
||||||
cost: null,
|
|
||||||
tokens: null,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className='fixed top-[16px] right-[16px] z-[100] max-h-[calc(100vh-8rem)] w-96 overflow-y-auto border bg-[var(--surface-1)] shadow-lg dark:border-[var(--border)] dark:bg-[var(--surface-1)]'>
|
<Card className='fixed top-[16px] right-[16px] z-[100] max-h-[calc(100vh-8rem)] w-96 overflow-y-auto rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)] shadow-lg'>
|
||||||
<CardHeader className='pb-[12px]'>
|
<CardHeader className='pb-[12px]'>
|
||||||
<div className='flex items-center justify-between'>
|
<div className='flex items-center justify-between'>
|
||||||
<CardTitle className='flex items-center gap-[8px] text-[15px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
|
<CardTitle className='flex items-center gap-[8px] text-[15px] text-[var(--text-primary)]'>
|
||||||
<Zap className='h-[16px] w-[16px]' />
|
<Zap className='h-[16px] w-[16px]' />
|
||||||
{formatted.blockName}
|
{formatted.blockName}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--border)] dark:text-[var(--text-secondary)] dark:hover:bg-[var(--border)]'
|
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)]'
|
||||||
|
type='button'
|
||||||
>
|
>
|
||||||
<X className='h-[14px] w-[14px]' />
|
<X className='h-[16px] w-[16px]' />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex items-center justify-between'>
|
<div className='flex items-center gap-[8px]'>
|
||||||
<div className='flex items-center gap-[8px]'>
|
<Badge variant='secondary'>{formatted.blockType}</Badge>
|
||||||
<Badge variant='secondary'>{formatted.blockType}</Badge>
|
<Badge variant='outline'>not executed</Badge>
|
||||||
<Badge variant='outline'>not executed</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className='space-y-[16px]'>
|
<CardContent className='space-y-[16px]'>
|
||||||
<div className='bg-[var(--surface-5)] p-[16px] text-center'>
|
<div className='rounded-[4px] border border-[var(--border)] bg-[var(--surface-3)] p-[16px] text-center'>
|
||||||
<div className='text-[13px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
|
<div className='text-[13px] text-[var(--text-secondary)]'>
|
||||||
This block was not executed because the workflow failed before reaching it.
|
This block was not executed because the workflow failed before reaching it.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -228,7 +216,6 @@ function PinnedLogs({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now we can safely use the execution data
|
|
||||||
const iterationInfo = getCurrentIterationData({
|
const iterationInfo = getCurrentIterationData({
|
||||||
...executionData,
|
...executionData,
|
||||||
currentIteration: currentIterationIndex,
|
currentIteration: currentIterationIndex,
|
||||||
@@ -250,18 +237,19 @@ function PinnedLogs({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className='fixed top-[16px] right-[16px] z-[100] max-h-[calc(100vh-8rem)] w-96 overflow-y-auto rounded-[14px] border bg-[var(--surface-1)] shadow-lg dark:border-[var(--border)] dark:bg-[var(--surface-1)]'>
|
<Card className='fixed top-[16px] right-[16px] z-[100] max-h-[calc(100vh-8rem)] w-96 overflow-y-auto rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)] shadow-lg'>
|
||||||
<CardHeader className='pb-[12px]'>
|
<CardHeader className='pb-[12px]'>
|
||||||
<div className='flex items-center justify-between'>
|
<div className='flex items-center justify-between'>
|
||||||
<CardTitle className='flex items-center gap-[8px] text-[15px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
|
<CardTitle className='flex items-center gap-[8px] text-[15px] text-[var(--text-primary)]'>
|
||||||
<Zap className='h-[16px] w-[16px]' />
|
<Zap className='h-[16px] w-[16px]' />
|
||||||
{formatted.blockName}
|
{formatted.blockName}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--border)] dark:text-[var(--text-secondary)] dark:hover:bg-[var(--border)]'
|
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)]'
|
||||||
|
type='button'
|
||||||
>
|
>
|
||||||
<X className='h-[14px] w-[14px]' />
|
<X className='h-[16px] w-[16px]' />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex items-center justify-between'>
|
<div className='flex items-center justify-between'>
|
||||||
@@ -272,17 +260,17 @@ function PinnedLogs({
|
|||||||
<Badge variant='outline'>{formatted.status}</Badge>
|
<Badge variant='outline'>{formatted.status}</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Iteration Navigation */}
|
|
||||||
{iterationInfo.hasMultipleIterations && (
|
{iterationInfo.hasMultipleIterations && (
|
||||||
<div className='flex items-center gap-[4px]'>
|
<div className='flex items-center gap-[4px]'>
|
||||||
<button
|
<button
|
||||||
onClick={goToPreviousIteration}
|
onClick={goToPreviousIteration}
|
||||||
disabled={currentIterationIndex === 0}
|
disabled={currentIterationIndex === 0}
|
||||||
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--border)] hover:text-[var(--text-primary)] disabled:cursor-not-allowed disabled:opacity-50 dark:text-[var(--text-secondary)] dark:hover:bg-[var(--border)] dark:hover:text-[var(--text-primary)]'
|
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)] disabled:cursor-not-allowed disabled:opacity-50'
|
||||||
|
type='button'
|
||||||
>
|
>
|
||||||
<ChevronLeft className='h-[14px] w-[14px]' />
|
<ChevronLeft className='h-[14px] w-[14px]' />
|
||||||
</button>
|
</button>
|
||||||
<span className='px-[8px] text-[12px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
|
<span className='px-[8px] text-[12px] text-[var(--text-tertiary)]'>
|
||||||
{iterationInfo.totalIterations !== undefined
|
{iterationInfo.totalIterations !== undefined
|
||||||
? `${currentIterationIndex + 1} / ${iterationInfo.totalIterations}`
|
? `${currentIterationIndex + 1} / ${iterationInfo.totalIterations}`
|
||||||
: `${currentIterationIndex + 1}`}
|
: `${currentIterationIndex + 1}`}
|
||||||
@@ -290,7 +278,8 @@ function PinnedLogs({
|
|||||||
<button
|
<button
|
||||||
onClick={goToNextIteration}
|
onClick={goToNextIteration}
|
||||||
disabled={currentIterationIndex === totalIterations - 1}
|
disabled={currentIterationIndex === totalIterations - 1}
|
||||||
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--border)] hover:text-[var(--text-primary)] disabled:cursor-not-allowed disabled:opacity-50 dark:text-[var(--text-secondary)] dark:hover:bg-[var(--border)] dark:hover:text-[var(--text-primary)]'
|
className='rounded-[4px] p-[4px] text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-3)] hover:text-[var(--text-primary)] disabled:cursor-not-allowed disabled:opacity-50'
|
||||||
|
type='button'
|
||||||
>
|
>
|
||||||
<ChevronRight className='h-[14px] w-[14px]' />
|
<ChevronRight className='h-[14px] w-[14px]' />
|
||||||
</button>
|
</button>
|
||||||
@@ -300,18 +289,16 @@ function PinnedLogs({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className='space-y-[16px]'>
|
<CardContent className='space-y-[16px]'>
|
||||||
<div className='grid grid-cols-2 gap-[16px]'>
|
<div className='grid grid-cols-2 gap-[12px]'>
|
||||||
<div className='flex items-center gap-[8px]'>
|
<div className='flex items-center gap-[8px]'>
|
||||||
<Clock className='h-[14px] w-[14px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]' />
|
<Clock className='h-[14px] w-[14px] text-[var(--text-secondary)]' />
|
||||||
<span className='text-[13px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
|
<span className='text-[13px] text-[var(--text-primary)]'>{formatted.duration}</span>
|
||||||
{formatted.duration}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{formatted.cost && formatted.cost.total > 0 && (
|
{formatted.cost && formatted.cost.total > 0 && (
|
||||||
<div className='flex items-center gap-[8px]'>
|
<div className='flex items-center gap-[8px]'>
|
||||||
<DollarSign className='h-[14px] w-[14px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]' />
|
<DollarSign className='h-[14px] w-[14px] text-[var(--text-secondary)]' />
|
||||||
<span className='text-[13px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
|
<span className='text-[13px] text-[var(--text-primary)]'>
|
||||||
${formatted.cost.total.toFixed(5)}
|
${formatted.cost.total.toFixed(5)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -319,8 +306,8 @@ function PinnedLogs({
|
|||||||
|
|
||||||
{formatted.tokens && formatted.tokens.total > 0 && (
|
{formatted.tokens && formatted.tokens.total > 0 && (
|
||||||
<div className='flex items-center gap-[8px]'>
|
<div className='flex items-center gap-[8px]'>
|
||||||
<Hash className='h-[14px] w-[14px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]' />
|
<Hash className='h-[14px] w-[14px] text-[var(--text-secondary)]' />
|
||||||
<span className='text-[13px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
|
<span className='text-[13px] text-[var(--text-primary)]'>
|
||||||
{formatted.tokens.total} tokens
|
{formatted.tokens.total} tokens
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -333,19 +320,19 @@ function PinnedLogs({
|
|||||||
|
|
||||||
{formatted.cost && formatted.cost.total > 0 && (
|
{formatted.cost && formatted.cost.total > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h4 className='mb-[8px] font-medium text-[13px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
|
<h4 className='mb-[6px] font-medium text-[13px] text-[var(--text-primary)]'>
|
||||||
Cost Breakdown
|
Cost Breakdown
|
||||||
</h4>
|
</h4>
|
||||||
<div className='space-y-[4px] text-[13px]'>
|
<div className='space-y-[4px] rounded-[4px] border border-[var(--border)] bg-[var(--surface-3)] p-[12px] text-[13px]'>
|
||||||
<div className='flex justify-between text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
|
<div className='flex justify-between text-[var(--text-primary)]'>
|
||||||
<span>Input:</span>
|
<span>Input:</span>
|
||||||
<span>${formatted.cost.input.toFixed(5)}</span>
|
<span>${formatted.cost.input.toFixed(5)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex justify-between text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
|
<div className='flex justify-between text-[var(--text-primary)]'>
|
||||||
<span>Output:</span>
|
<span>Output:</span>
|
||||||
<span>${formatted.cost.output.toFixed(5)}</span>
|
<span>${formatted.cost.output.toFixed(5)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex justify-between border-t pt-[4px] font-medium text-[var(--text-primary)] dark:border-[var(--border)] dark:text-[var(--text-primary)]'>
|
<div className='flex justify-between border-[var(--border)] border-t pt-[4px] font-medium text-[var(--text-primary)]'>
|
||||||
<span>Total:</span>
|
<span>Total:</span>
|
||||||
<span>${formatted.cost.total.toFixed(5)}</span>
|
<span>${formatted.cost.total.toFixed(5)}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -355,19 +342,19 @@ function PinnedLogs({
|
|||||||
|
|
||||||
{formatted.tokens && formatted.tokens.total > 0 && (
|
{formatted.tokens && formatted.tokens.total > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h4 className='mb-[8px] font-medium text-[13px] text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
|
<h4 className='mb-[6px] font-medium text-[13px] text-[var(--text-primary)]'>
|
||||||
Token Usage
|
Token Usage
|
||||||
</h4>
|
</h4>
|
||||||
<div className='space-y-[4px] text-[13px]'>
|
<div className='space-y-[4px] rounded-[4px] border border-[var(--border)] bg-[var(--surface-3)] p-[12px] text-[13px]'>
|
||||||
<div className='flex justify-between text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
|
<div className='flex justify-between text-[var(--text-primary)]'>
|
||||||
<span>Prompt:</span>
|
<span>Prompt:</span>
|
||||||
<span>{formatted.tokens.prompt}</span>
|
<span>{formatted.tokens.prompt}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex justify-between text-[var(--text-primary)] dark:text-[var(--text-primary)]'>
|
<div className='flex justify-between text-[var(--text-primary)]'>
|
||||||
<span>Completion:</span>
|
<span>Completion:</span>
|
||||||
<span>{formatted.tokens.completion}</span>
|
<span>{formatted.tokens.completion}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex justify-between border-t pt-[4px] font-medium text-[var(--text-primary)] dark:border-[var(--border)] dark:text-[var(--text-primary)]'>
|
<div className='flex justify-between border-[var(--border)] border-t pt-[4px] font-medium text-[var(--text-primary)]'>
|
||||||
<span>Total:</span>
|
<span>Total:</span>
|
||||||
<span>{formatted.tokens.total}</span>
|
<span>{formatted.tokens.total}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -404,6 +391,9 @@ interface FrozenCanvasProps {
|
|||||||
className?: string
|
className?: string
|
||||||
height?: string | number
|
height?: string | number
|
||||||
width?: string | number
|
width?: string | number
|
||||||
|
isModal?: boolean
|
||||||
|
isOpen?: boolean
|
||||||
|
onClose?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FrozenCanvas({
|
export function FrozenCanvas({
|
||||||
@@ -412,6 +402,9 @@ export function FrozenCanvas({
|
|||||||
className,
|
className,
|
||||||
height = '100%',
|
height = '100%',
|
||||||
width = '100%',
|
width = '100%',
|
||||||
|
isModal = false,
|
||||||
|
isOpen = false,
|
||||||
|
onClose,
|
||||||
}: FrozenCanvasProps) {
|
}: FrozenCanvasProps) {
|
||||||
const [data, setData] = useState<FrozenCanvasData | null>(null)
|
const [data, setData] = useState<FrozenCanvasData | null>(null)
|
||||||
const [blockExecutions, setBlockExecutions] = useState<Record<string, any>>({})
|
const [blockExecutions, setBlockExecutions] = useState<Record<string, any>>({})
|
||||||
@@ -551,86 +544,115 @@ export function FrozenCanvas({
|
|||||||
fetchData()
|
fetchData()
|
||||||
}, [executionId])
|
}, [executionId])
|
||||||
|
|
||||||
if (loading) {
|
const renderContent = () => {
|
||||||
return (
|
if (loading) {
|
||||||
<div className={cn('flex items-center justify-center', className)} style={{ height, width }}>
|
return (
|
||||||
<div className='flex items-center gap-[8px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
|
<div
|
||||||
<Loader2 className='h-[16px] w-[16px] animate-spin' />
|
className={cn('flex items-center justify-center', className)}
|
||||||
<span className='text-[13px]'>Loading frozen canvas...</span>
|
style={{ height, width }}
|
||||||
|
>
|
||||||
|
<div className='flex items-center gap-[8px] text-[var(--text-secondary)]'>
|
||||||
|
<Loader2 className='h-[16px] w-[16px] animate-spin' />
|
||||||
|
<span className='text-[13px]'>Loading frozen canvas...</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn('flex items-center justify-center', className)}
|
||||||
|
style={{ height, width }}
|
||||||
|
>
|
||||||
|
<div className='flex items-center gap-[8px] text-[var(--text-error)]'>
|
||||||
|
<AlertCircle className='h-[16px] w-[16px]' />
|
||||||
|
<span className='text-[13px]'>Failed to load frozen canvas: {error}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn('flex items-center justify-center', className)}
|
||||||
|
style={{ height, width }}
|
||||||
|
>
|
||||||
|
<div className='text-[13px] text-[var(--text-secondary)]'>No data available</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMigratedLog = (data.workflowState as any)?._migrated === true
|
||||||
|
if (isMigratedLog) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn('flex flex-col items-center justify-center gap-[16px] p-[32px]', className)}
|
||||||
|
style={{ height, width }}
|
||||||
|
>
|
||||||
|
<div className='flex items-center gap-[12px] text-[var(--text-warning)]'>
|
||||||
|
<AlertCircle className='h-[20px] w-[20px]' />
|
||||||
|
<span className='font-medium text-[15px]'>Logged State Not Found</span>
|
||||||
|
</div>
|
||||||
|
<div className='max-w-md text-center text-[13px] text-[var(--text-secondary)]'>
|
||||||
|
This log was migrated from the old logging system. The workflow state at execution time
|
||||||
|
is not available.
|
||||||
|
</div>
|
||||||
|
<div className='text-[12px] text-[var(--text-tertiary)]'>
|
||||||
|
Note: {(data.workflowState as any)?._note}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
style={{ height, width }}
|
||||||
|
className={cn('frozen-canvas-mode h-full w-full', className)}
|
||||||
|
>
|
||||||
|
<WorkflowPreview
|
||||||
|
workflowState={data.workflowState}
|
||||||
|
showSubBlocks={true}
|
||||||
|
isPannable={true}
|
||||||
|
defaultPosition={{ x: 0, y: 0 }}
|
||||||
|
defaultZoom={0.8}
|
||||||
|
onNodeClick={(blockId) => {
|
||||||
|
setPinnedBlockId(blockId)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pinnedBlockId && (
|
||||||
|
<PinnedLogs
|
||||||
|
executionData={blockExecutions[pinnedBlockId] || null}
|
||||||
|
blockId={pinnedBlockId}
|
||||||
|
workflowState={data.workflowState}
|
||||||
|
onClose={() => setPinnedBlockId(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (isModal) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex items-center justify-center', className)} style={{ height, width }}>
|
<Modal open={isOpen} onOpenChange={onClose}>
|
||||||
<div className='flex items-center gap-[8px] text-[var(--text-error)] dark:text-[var(--text-error)]'>
|
<ModalContent size='xl' className='flex h-[90vh] flex-col'>
|
||||||
<AlertCircle className='h-[16px] w-[16px]' />
|
<ModalHeader>Workflow State</ModalHeader>
|
||||||
<span className='text-[13px]'>Failed to load frozen canvas: {error}</span>
|
|
||||||
</div>
|
<ModalBody className='min-h-0 flex-1'>
|
||||||
</div>
|
<div className='flex h-full flex-col'>
|
||||||
|
<div className='min-h-0 flex-1 overflow-hidden rounded-[4px] border border-[var(--border)]'>
|
||||||
|
{renderContent()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalBody>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data) {
|
return renderContent()
|
||||||
return (
|
|
||||||
<div className={cn('flex items-center justify-center', className)} style={{ height, width }}>
|
|
||||||
<div className='text-[13px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
|
|
||||||
No data available
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this is a migrated log without real workflow state
|
|
||||||
const isMigratedLog = (data.workflowState as any)?._migrated === true
|
|
||||||
if (isMigratedLog) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn('flex flex-col items-center justify-center gap-[16px] p-[32px]', className)}
|
|
||||||
style={{ height, width }}
|
|
||||||
>
|
|
||||||
<div className='flex items-center gap-[12px] text-amber-600 dark:text-amber-400'>
|
|
||||||
<AlertCircle className='h-[20px] w-[20px]' />
|
|
||||||
<span className='font-medium text-[15px]'>Logged State Not Found</span>
|
|
||||||
</div>
|
|
||||||
<div className='max-w-md text-center text-[13px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
|
|
||||||
This log was migrated from the old logging system. The workflow state at execution time is
|
|
||||||
not available.
|
|
||||||
</div>
|
|
||||||
<div className='text-[12px] text-[var(--text-secondary)] dark:text-[var(--text-secondary)]'>
|
|
||||||
Note: {(data.workflowState as any)?._note}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div style={{ height, width }} className={cn('frozen-canvas-mode h-full w-full', className)}>
|
|
||||||
<WorkflowPreview
|
|
||||||
workflowState={data.workflowState}
|
|
||||||
showSubBlocks={true}
|
|
||||||
isPannable={true}
|
|
||||||
defaultZoom={0.8}
|
|
||||||
fitPadding={0.25}
|
|
||||||
onNodeClick={(blockId) => {
|
|
||||||
// Always allow clicking blocks, even if they don't have execution data
|
|
||||||
// This is important for failed workflows where some blocks never executed
|
|
||||||
setPinnedBlockId(blockId)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{pinnedBlockId && (
|
|
||||||
<PinnedLogs
|
|
||||||
executionData={blockExecutions[pinnedBlockId] || null}
|
|
||||||
blockId={pinnedBlockId}
|
|
||||||
workflowState={data.workflowState}
|
|
||||||
onClose={() => setPinnedBlockId(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { FrozenCanvas } from './frozen-canvas'
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { TraceSpans } from './trace-spans'
|
||||||
@@ -0,0 +1,630 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import type React from 'react'
|
||||||
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
|
import { highlight, languages } from 'prismjs'
|
||||||
|
import 'prismjs/components/prism-json'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
import { Button, ChevronDown } from '@/components/emcn'
|
||||||
|
import type { TraceSpan } from '@/stores/logs/filters/types'
|
||||||
|
import '@/components/emcn/components/code/code.css'
|
||||||
|
import { LoopTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/loop/loop-config'
|
||||||
|
import { ParallelTool } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/subflows/parallel/parallel-config'
|
||||||
|
import { getBlock, getBlockByToolName } from '@/blocks'
|
||||||
|
|
||||||
|
interface TraceSpansProps {
|
||||||
|
traceSpans?: TraceSpan[]
|
||||||
|
totalDuration?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a unique key for a trace span
|
||||||
|
*/
|
||||||
|
function getSpanKey(span: TraceSpan): string {
|
||||||
|
if (span.id) {
|
||||||
|
return span.id
|
||||||
|
}
|
||||||
|
const name = span.name || 'span'
|
||||||
|
const start = span.startTime || 'unknown-start'
|
||||||
|
const end = span.endTime || 'unknown-end'
|
||||||
|
return `${name}|${start}|${end}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merges multiple arrays of trace span children, deduplicating by span key
|
||||||
|
*/
|
||||||
|
function mergeTraceSpanChildren(...groups: TraceSpan[][]): TraceSpan[] {
|
||||||
|
const merged: TraceSpan[] = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
|
||||||
|
groups.forEach((group) => {
|
||||||
|
group.forEach((child) => {
|
||||||
|
const key = getSpanKey(child)
|
||||||
|
if (seen.has(key)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen.add(key)
|
||||||
|
merged.push(child)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes a trace span by merging children from both the children array
|
||||||
|
* and any childTraceSpans in the output
|
||||||
|
*/
|
||||||
|
function normalizeChildWorkflowSpan(span: TraceSpan): TraceSpan {
|
||||||
|
const enrichedSpan: TraceSpan = { ...span }
|
||||||
|
|
||||||
|
if (enrichedSpan.output && typeof enrichedSpan.output === 'object') {
|
||||||
|
enrichedSpan.output = { ...enrichedSpan.output }
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedChildren = Array.isArray(span.children)
|
||||||
|
? span.children.map((childSpan) => normalizeChildWorkflowSpan(childSpan))
|
||||||
|
: []
|
||||||
|
|
||||||
|
const outputChildSpans = Array.isArray(span.output?.childTraceSpans)
|
||||||
|
? (span.output!.childTraceSpans as TraceSpan[]).map((childSpan) =>
|
||||||
|
normalizeChildWorkflowSpan(childSpan)
|
||||||
|
)
|
||||||
|
: []
|
||||||
|
|
||||||
|
const mergedChildren = mergeTraceSpanChildren(normalizedChildren, outputChildSpans)
|
||||||
|
|
||||||
|
if (
|
||||||
|
enrichedSpan.output &&
|
||||||
|
typeof enrichedSpan.output === 'object' &&
|
||||||
|
enrichedSpan.output !== null &&
|
||||||
|
'childTraceSpans' in enrichedSpan.output
|
||||||
|
) {
|
||||||
|
const { childTraceSpans, ...cleanOutput } = enrichedSpan.output as {
|
||||||
|
childTraceSpans?: TraceSpan[]
|
||||||
|
} & Record<string, unknown>
|
||||||
|
enrichedSpan.output = cleanOutput
|
||||||
|
}
|
||||||
|
|
||||||
|
enrichedSpan.children = mergedChildren.length > 0 ? mergedChildren : undefined
|
||||||
|
|
||||||
|
return enrichedSpan
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats duration in ms
|
||||||
|
*/
|
||||||
|
function formatDuration(ms: number): string {
|
||||||
|
if (ms < 1000) return `${ms}ms`
|
||||||
|
return `${(ms / 1000).toFixed(2)}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets color for block type
|
||||||
|
*/
|
||||||
|
function getBlockColor(type: string): string {
|
||||||
|
switch (type.toLowerCase()) {
|
||||||
|
case 'agent':
|
||||||
|
return 'var(--brand-primary-hover-hex)'
|
||||||
|
case 'model':
|
||||||
|
return 'var(--brand-primary-hover-hex)'
|
||||||
|
case 'function':
|
||||||
|
return '#FF402F'
|
||||||
|
case 'tool':
|
||||||
|
return '#f97316'
|
||||||
|
case 'router':
|
||||||
|
return '#2FA1FF'
|
||||||
|
case 'condition':
|
||||||
|
return '#FF972F'
|
||||||
|
case 'evaluator':
|
||||||
|
return '#2FA1FF'
|
||||||
|
case 'api':
|
||||||
|
return '#2F55FF'
|
||||||
|
default:
|
||||||
|
return '#6b7280'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets icon and color for block type
|
||||||
|
*/
|
||||||
|
function getBlockIconAndColor(type: string): {
|
||||||
|
icon: React.ComponentType<{ className?: string }> | null
|
||||||
|
bgColor: string
|
||||||
|
} {
|
||||||
|
const lowerType = type.toLowerCase()
|
||||||
|
|
||||||
|
if (lowerType === 'loop') {
|
||||||
|
return { icon: LoopTool.icon, bgColor: LoopTool.bgColor }
|
||||||
|
}
|
||||||
|
if (lowerType === 'parallel') {
|
||||||
|
return { icon: ParallelTool.icon, bgColor: ParallelTool.bgColor }
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockType = lowerType === 'model' ? 'agent' : lowerType
|
||||||
|
const blockConfig = getBlock(blockType)
|
||||||
|
if (blockConfig) {
|
||||||
|
return { icon: blockConfig.icon, bgColor: blockConfig.bgColor }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { icon: null, bgColor: getBlockColor(type) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the progress bar showing execution timeline
|
||||||
|
*/
|
||||||
|
function ProgressBar({
|
||||||
|
span,
|
||||||
|
childSpans,
|
||||||
|
workflowStartTime,
|
||||||
|
totalDuration,
|
||||||
|
}: {
|
||||||
|
span: TraceSpan
|
||||||
|
childSpans?: TraceSpan[]
|
||||||
|
workflowStartTime: number
|
||||||
|
totalDuration: number
|
||||||
|
}) {
|
||||||
|
const segments = useMemo(() => {
|
||||||
|
if (!childSpans || childSpans.length === 0) {
|
||||||
|
const startMs = new Date(span.startTime).getTime()
|
||||||
|
const endMs = new Date(span.endTime).getTime()
|
||||||
|
const duration = endMs - startMs
|
||||||
|
const startPercent =
|
||||||
|
totalDuration > 0 ? ((startMs - workflowStartTime) / totalDuration) * 100 : 0
|
||||||
|
const widthPercent = totalDuration > 0 ? (duration / totalDuration) * 100 : 0
|
||||||
|
|
||||||
|
let color = getBlockColor(span.type)
|
||||||
|
if (span.type?.toLowerCase() === 'tool' && span.name) {
|
||||||
|
const toolBlock = getBlockByToolName(span.name)
|
||||||
|
if (toolBlock?.bgColor) {
|
||||||
|
color = toolBlock.bgColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
startPercent: Math.max(0, Math.min(100, startPercent)),
|
||||||
|
widthPercent: Math.max(0.5, Math.min(100, widthPercent)),
|
||||||
|
color,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return childSpans.map((child) => {
|
||||||
|
const startMs = new Date(child.startTime).getTime()
|
||||||
|
const endMs = new Date(child.endTime).getTime()
|
||||||
|
const duration = endMs - startMs
|
||||||
|
const startPercent =
|
||||||
|
totalDuration > 0 ? ((startMs - workflowStartTime) / totalDuration) * 100 : 0
|
||||||
|
const widthPercent = totalDuration > 0 ? (duration / totalDuration) * 100 : 0
|
||||||
|
|
||||||
|
let color = getBlockColor(child.type)
|
||||||
|
if (child.type?.toLowerCase() === 'tool' && child.name) {
|
||||||
|
const toolBlock = getBlockByToolName(child.name)
|
||||||
|
if (toolBlock?.bgColor) {
|
||||||
|
color = toolBlock.bgColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
startPercent: Math.max(0, Math.min(100, startPercent)),
|
||||||
|
widthPercent: Math.max(0.5, Math.min(100, widthPercent)),
|
||||||
|
color,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}, [span, childSpans, workflowStartTime, totalDuration])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='relative mb-[8px] h-[5px] w-full overflow-hidden rounded-[18px] bg-[var(--divider)]'>
|
||||||
|
{segments.map((segment, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className='absolute h-full'
|
||||||
|
style={{
|
||||||
|
left: `${segment.startPercent}%`,
|
||||||
|
width: `${segment.widthPercent}%`,
|
||||||
|
backgroundColor: segment.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders input/output section with collapsible content
|
||||||
|
*/
|
||||||
|
function InputOutputSection({
|
||||||
|
label,
|
||||||
|
data,
|
||||||
|
isError,
|
||||||
|
spanId,
|
||||||
|
sectionType,
|
||||||
|
expandedSections,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
data: unknown
|
||||||
|
isError: boolean
|
||||||
|
spanId: string
|
||||||
|
sectionType: 'input' | 'output'
|
||||||
|
expandedSections: Set<string>
|
||||||
|
onToggle: (section: string) => void
|
||||||
|
}) {
|
||||||
|
const sectionKey = `${spanId}-${sectionType}`
|
||||||
|
const isExpanded = expandedSections.has(sectionKey)
|
||||||
|
|
||||||
|
const jsonString = useMemo(() => {
|
||||||
|
if (!data) return ''
|
||||||
|
return JSON.stringify(data, null, 2)
|
||||||
|
}, [data])
|
||||||
|
|
||||||
|
const highlightedCode = useMemo(() => {
|
||||||
|
if (!jsonString) return ''
|
||||||
|
return highlight(jsonString, languages.json, 'json')
|
||||||
|
}, [jsonString])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex flex-col gap-[8px]'>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<span
|
||||||
|
className='font-medium text-[12px]'
|
||||||
|
style={{ color: isError ? 'var(--text-error)' : 'var(--text-tertiary)' }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
className='!h-[18px] !w-[18px] !p-0'
|
||||||
|
onClick={() => onToggle(sectionKey)}
|
||||||
|
>
|
||||||
|
<ChevronDown
|
||||||
|
className='h-[10px] w-[10px] text-[var(--text-subtle)] transition-transform'
|
||||||
|
style={{
|
||||||
|
transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{isExpanded && (
|
||||||
|
<div>
|
||||||
|
{isError && typeof data === 'object' && data !== null && 'error' in data ? (
|
||||||
|
<div
|
||||||
|
className='rounded-[6px] px-[10px] py-[8px]'
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--terminal-status-error-bg)',
|
||||||
|
color: 'var(--text-error)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className='font-medium text-[12px]'>Error</div>
|
||||||
|
<div className='mt-[4px] text-[12px]'>{(data as { error: string }).error}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className='code-editor-theme overflow-hidden rounded-[6px] bg-[var(--surface-3)] px-[10px] py-[8px]'>
|
||||||
|
<pre
|
||||||
|
className='m-0 w-full overflow-y-auto overflow-x-hidden whitespace-pre-wrap break-all font-mono text-[#eeeeee] text-[11px] leading-[16px]'
|
||||||
|
dangerouslySetInnerHTML={{ __html: highlightedCode }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TraceSpanItemProps {
|
||||||
|
span: TraceSpan
|
||||||
|
totalDuration: number
|
||||||
|
workflowStartTime: number
|
||||||
|
onToggle: (spanId: string, expanded: boolean) => void
|
||||||
|
expandedSpans: Set<string>
|
||||||
|
isFirstSpan?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Individual trace span card component
|
||||||
|
*/
|
||||||
|
function TraceSpanItem({
|
||||||
|
span,
|
||||||
|
totalDuration,
|
||||||
|
workflowStartTime,
|
||||||
|
onToggle,
|
||||||
|
expandedSpans,
|
||||||
|
isFirstSpan = false,
|
||||||
|
}: TraceSpanItemProps): React.ReactNode {
|
||||||
|
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
const spanId = span.id || `span-${span.name}-${span.startTime}`
|
||||||
|
const spanStartTime = new Date(span.startTime).getTime()
|
||||||
|
const spanEndTime = new Date(span.endTime).getTime()
|
||||||
|
const duration = span.duration || spanEndTime - spanStartTime
|
||||||
|
|
||||||
|
const hasChildren = span.children && span.children.length > 0
|
||||||
|
const hasToolCalls = span.toolCalls && span.toolCalls.length > 0
|
||||||
|
const hasInput = Boolean(span.input)
|
||||||
|
const hasOutput = Boolean(span.output)
|
||||||
|
const isError = span.status === 'error'
|
||||||
|
|
||||||
|
const inlineChildTypes = new Set(['tool', 'model'])
|
||||||
|
const inlineChildren =
|
||||||
|
span.children?.filter((child) => inlineChildTypes.has(child.type?.toLowerCase() || '')) || []
|
||||||
|
const otherChildren =
|
||||||
|
span.children?.filter((child) => !inlineChildTypes.has(child.type?.toLowerCase() || '')) || []
|
||||||
|
|
||||||
|
const toolCallSpans = useMemo(() => {
|
||||||
|
if (!hasToolCalls) return []
|
||||||
|
return span.toolCalls!.map((toolCall, index) => {
|
||||||
|
const toolStartTime = toolCall.startTime
|
||||||
|
? new Date(toolCall.startTime).getTime()
|
||||||
|
: spanStartTime
|
||||||
|
const toolEndTime = toolCall.endTime
|
||||||
|
? new Date(toolCall.endTime).getTime()
|
||||||
|
: toolStartTime + (toolCall.duration || 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `${spanId}-tool-${index}`,
|
||||||
|
name: toolCall.name,
|
||||||
|
type: 'tool',
|
||||||
|
duration: toolCall.duration || toolEndTime - toolStartTime,
|
||||||
|
startTime: new Date(toolStartTime).toISOString(),
|
||||||
|
endTime: new Date(toolEndTime).toISOString(),
|
||||||
|
status: toolCall.error ? ('error' as const) : ('success' as const),
|
||||||
|
input: toolCall.input,
|
||||||
|
output: toolCall.error
|
||||||
|
? { error: toolCall.error, ...(toolCall.output || {}) }
|
||||||
|
: toolCall.output,
|
||||||
|
} as TraceSpan
|
||||||
|
})
|
||||||
|
}, [hasToolCalls, span.toolCalls, spanId, spanStartTime])
|
||||||
|
|
||||||
|
const handleSectionToggle = (section: string) => {
|
||||||
|
setExpandedSections((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(section)) {
|
||||||
|
next.delete(section)
|
||||||
|
} else {
|
||||||
|
next.add(section)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const { icon: BlockIcon, bgColor } = getBlockIconAndColor(span.type)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className='flex flex-col gap-[8px] rounded-[6px] bg-[var(--surface-1)] px-[10px] py-[8px]'>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<div className='flex items-center gap-[8px]'>
|
||||||
|
{!isFirstSpan && (
|
||||||
|
<div
|
||||||
|
className='relative flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
|
||||||
|
style={{ background: bgColor }}
|
||||||
|
>
|
||||||
|
{BlockIcon && <BlockIcon className={clsx('text-white', '!h-[9px] !w-[9px]')} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className='font-medium text-[12px]'
|
||||||
|
style={{ color: isError ? 'var(--text-error)' : 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
{span.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||||
|
{formatDuration(duration)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
span={span}
|
||||||
|
childSpans={span.children}
|
||||||
|
workflowStartTime={workflowStartTime}
|
||||||
|
totalDuration={totalDuration}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{hasInput && (
|
||||||
|
<InputOutputSection
|
||||||
|
label='Input'
|
||||||
|
data={span.input}
|
||||||
|
isError={false}
|
||||||
|
spanId={spanId}
|
||||||
|
sectionType='input'
|
||||||
|
expandedSections={expandedSections}
|
||||||
|
onToggle={handleSectionToggle}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasInput && hasOutput && <div className='border-[var(--border)] border-t border-dashed' />}
|
||||||
|
|
||||||
|
{hasOutput && (
|
||||||
|
<InputOutputSection
|
||||||
|
label={isError ? 'Error' : 'Output'}
|
||||||
|
data={span.output}
|
||||||
|
isError={isError}
|
||||||
|
spanId={spanId}
|
||||||
|
sectionType='output'
|
||||||
|
expandedSections={expandedSections}
|
||||||
|
onToggle={handleSectionToggle}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(hasToolCalls || inlineChildren.length > 0) &&
|
||||||
|
[...toolCallSpans, ...inlineChildren].map((childSpan, index) => {
|
||||||
|
const childId = childSpan.id || `${spanId}-inline-${index}`
|
||||||
|
const childIsError = childSpan.status === 'error'
|
||||||
|
const isInitialResponse = (childSpan.name || '')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes('initial response')
|
||||||
|
|
||||||
|
const shouldRenderSeparator =
|
||||||
|
index === 0 && (hasInput || hasOutput) && !isInitialResponse
|
||||||
|
|
||||||
|
const toolBlock =
|
||||||
|
childSpan.type?.toLowerCase() === 'tool' && childSpan.name
|
||||||
|
? getBlockByToolName(childSpan.name)
|
||||||
|
: null
|
||||||
|
const { icon: ChildIcon, bgColor: childBgColor } = toolBlock
|
||||||
|
? { icon: toolBlock.icon, bgColor: toolBlock.bgColor }
|
||||||
|
: getBlockIconAndColor(childSpan.type)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={`inline-${childId}`}>
|
||||||
|
{shouldRenderSeparator && (
|
||||||
|
<div className='border-[var(--border)] border-t border-dashed' />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='mt-[8px] flex flex-col gap-[8px]'>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<div className='flex items-center gap-[8px]'>
|
||||||
|
<div
|
||||||
|
className='relative flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center overflow-hidden rounded-[4px]'
|
||||||
|
style={{ background: childBgColor }}
|
||||||
|
>
|
||||||
|
{ChildIcon && (
|
||||||
|
<ChildIcon className={clsx('text-white', '!h-[9px] !w-[9px]')} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className='font-medium text-[12px]'
|
||||||
|
style={{
|
||||||
|
color: childIsError ? 'var(--text-error)' : 'var(--text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{childSpan.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||||
|
{formatDuration(childSpan.duration || 0)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
span={childSpan}
|
||||||
|
childSpans={undefined}
|
||||||
|
workflowStartTime={workflowStartTime}
|
||||||
|
totalDuration={totalDuration}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{childSpan.input && (
|
||||||
|
<InputOutputSection
|
||||||
|
label='Input'
|
||||||
|
data={childSpan.input}
|
||||||
|
isError={false}
|
||||||
|
spanId={`${childId}-input`}
|
||||||
|
sectionType='input'
|
||||||
|
expandedSections={expandedSections}
|
||||||
|
onToggle={handleSectionToggle}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{childSpan.input && childSpan.output && (
|
||||||
|
<div className='border-[var(--border)] border-t border-dashed' />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{childSpan.output && (
|
||||||
|
<InputOutputSection
|
||||||
|
label={childIsError ? 'Error' : 'Output'}
|
||||||
|
data={childSpan.output}
|
||||||
|
isError={childIsError}
|
||||||
|
spanId={`${childId}-output`}
|
||||||
|
sectionType='output'
|
||||||
|
expandedSections={expandedSections}
|
||||||
|
onToggle={handleSectionToggle}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{otherChildren.map((childSpan, index) => {
|
||||||
|
const enrichedChildSpan = normalizeChildWorkflowSpan(childSpan)
|
||||||
|
return (
|
||||||
|
<TraceSpanItem
|
||||||
|
key={index}
|
||||||
|
span={enrichedChildSpan}
|
||||||
|
totalDuration={totalDuration}
|
||||||
|
workflowStartTime={workflowStartTime}
|
||||||
|
onToggle={onToggle}
|
||||||
|
expandedSpans={expandedSpans}
|
||||||
|
isFirstSpan={false}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays workflow execution trace spans with nested structure
|
||||||
|
*/
|
||||||
|
export function TraceSpans({ traceSpans, totalDuration = 0 }: TraceSpansProps) {
|
||||||
|
const [expandedSpans, setExpandedSpans] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
const workflowStartTime = useMemo(() => {
|
||||||
|
if (!traceSpans || traceSpans.length === 0) return 0
|
||||||
|
return traceSpans.reduce((earliest, span) => {
|
||||||
|
const startTime = new Date(span.startTime).getTime()
|
||||||
|
return startTime < earliest ? startTime : earliest
|
||||||
|
}, Number.POSITIVE_INFINITY)
|
||||||
|
}, [traceSpans])
|
||||||
|
|
||||||
|
const workflowEndTime = useMemo(() => {
|
||||||
|
if (!traceSpans || traceSpans.length === 0) return 0
|
||||||
|
return traceSpans.reduce((latest, span) => {
|
||||||
|
const endTime = span.endTime ? new Date(span.endTime).getTime() : 0
|
||||||
|
return endTime > latest ? endTime : latest
|
||||||
|
}, 0)
|
||||||
|
}, [traceSpans])
|
||||||
|
|
||||||
|
const actualTotalDuration = workflowEndTime - workflowStartTime
|
||||||
|
|
||||||
|
const handleSpanToggle = useCallback((spanId: string, expanded: boolean) => {
|
||||||
|
setExpandedSpans((prev) => {
|
||||||
|
const newExpandedSpans = new Set(prev)
|
||||||
|
if (expanded) {
|
||||||
|
newExpandedSpans.add(spanId)
|
||||||
|
} else {
|
||||||
|
newExpandedSpans.delete(spanId)
|
||||||
|
}
|
||||||
|
return newExpandedSpans
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const filterTree = (spans: TraceSpan[]): TraceSpan[] =>
|
||||||
|
spans
|
||||||
|
.map((s) => normalizeChildWorkflowSpan(s))
|
||||||
|
.map((s) => ({
|
||||||
|
...s,
|
||||||
|
children: s.children ? filterTree(s.children) : undefined,
|
||||||
|
}))
|
||||||
|
return traceSpans ? filterTree(traceSpans) : []
|
||||||
|
}, [traceSpans])
|
||||||
|
|
||||||
|
if (!traceSpans || traceSpans.length === 0) {
|
||||||
|
return <div className='text-[12px] text-[var(--text-secondary)]'>No trace data available</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='flex w-full flex-col gap-[6px] rounded-[6px] bg-[var(--surface-2)] px-[10px] py-[8px]'>
|
||||||
|
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>Trace Span</span>
|
||||||
|
<div className='flex flex-col gap-[8px]'>
|
||||||
|
{filtered.map((span, index) => (
|
||||||
|
<TraceSpanItem
|
||||||
|
key={index}
|
||||||
|
span={span}
|
||||||
|
totalDuration={actualTotalDuration !== undefined ? actualTotalDuration : totalDuration}
|
||||||
|
workflowStartTime={workflowStartTime}
|
||||||
|
onToggle={handleSpanToggle}
|
||||||
|
expandedSpans={expandedSpans}
|
||||||
|
isFirstSpan={index === 0}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { LogDetails } from './log-details'
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { ChevronUp, X } from 'lucide-react'
|
||||||
|
import { Button, Eye } from '@/components/emcn'
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
|
import { BASE_EXECUTION_CHARGE } from '@/lib/billing/constants'
|
||||||
|
import { FileCards, FrozenCanvas, TraceSpans } from '@/app/workspace/[workspaceId]/logs/components'
|
||||||
|
import type { LogStatus } from '@/app/workspace/[workspaceId]/logs/utils'
|
||||||
|
import { formatDate, StatusBadge, TriggerBadge } from '@/app/workspace/[workspaceId]/logs/utils'
|
||||||
|
import { formatCost } from '@/providers/utils'
|
||||||
|
import type { WorkflowLog } from '@/stores/logs/filters/types'
|
||||||
|
|
||||||
|
interface LogDetailsProps {
|
||||||
|
/** The log to display details for */
|
||||||
|
log: WorkflowLog | null
|
||||||
|
/** Whether the sidebar is open */
|
||||||
|
isOpen: boolean
|
||||||
|
/** Callback when closing the sidebar */
|
||||||
|
onClose: () => void
|
||||||
|
/** Callback to navigate to next log */
|
||||||
|
onNavigateNext?: () => void
|
||||||
|
/** Callback to navigate to previous log */
|
||||||
|
onNavigatePrev?: () => void
|
||||||
|
/** Whether there is a next log available */
|
||||||
|
hasNext?: boolean
|
||||||
|
/** Whether there is a previous log available */
|
||||||
|
hasPrev?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sidebar panel displaying detailed information about a selected log.
|
||||||
|
* Supports navigation between logs and expandable sections.
|
||||||
|
* @param props - Component props
|
||||||
|
* @returns Log details sidebar component
|
||||||
|
*/
|
||||||
|
export function LogDetails({
|
||||||
|
log,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onNavigateNext,
|
||||||
|
onNavigatePrev,
|
||||||
|
hasNext = false,
|
||||||
|
hasPrev = false,
|
||||||
|
}: LogDetailsProps) {
|
||||||
|
const [isFrozenCanvasOpen, setIsFrozenCanvasOpen] = useState(false)
|
||||||
|
const scrollAreaRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollAreaRef.current) {
|
||||||
|
scrollAreaRef.current.scrollTop = 0
|
||||||
|
}
|
||||||
|
}, [log?.id])
|
||||||
|
|
||||||
|
const isWorkflowExecutionLog = useMemo(() => {
|
||||||
|
if (!log) return false
|
||||||
|
return (
|
||||||
|
(log.trigger === 'manual' && !!log.duration) ||
|
||||||
|
(log.executionData?.enhanced && log.executionData?.traceSpans)
|
||||||
|
)
|
||||||
|
}, [log])
|
||||||
|
|
||||||
|
const hasCostInfo = useMemo(() => {
|
||||||
|
return isWorkflowExecutionLog && log?.cost
|
||||||
|
}, [log, isWorkflowExecutionLog])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && isOpen) {
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
if (e.key === 'ArrowUp' && hasPrev && onNavigatePrev) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleNavigate(onNavigatePrev)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown' && hasNext && onNavigateNext) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleNavigate(onNavigateNext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [isOpen, onClose, hasPrev, hasNext, onNavigatePrev, onNavigateNext])
|
||||||
|
|
||||||
|
const handleNavigate = (navigateFunction: () => void) => {
|
||||||
|
navigateFunction()
|
||||||
|
}
|
||||||
|
|
||||||
|
const formattedTimestamp = log ? formatDate(log.createdAt) : null
|
||||||
|
|
||||||
|
const logStatus: LogStatus = useMemo(() => {
|
||||||
|
if (!log) return 'info'
|
||||||
|
const baseLevel = (log.level || 'info').toLowerCase()
|
||||||
|
const isError = baseLevel === 'error'
|
||||||
|
const isPending = !isError && log.hasPendingPause === true
|
||||||
|
const isRunning = !isError && !isPending && log.duration === null
|
||||||
|
return isError ? 'error' : isPending ? 'pending' : isRunning ? 'running' : 'info'
|
||||||
|
}, [log])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`absolute top-[0px] right-0 bottom-0 z-50 w-[384px] transform overflow-hidden border-l bg-[var(--surface-1)] shadow-lg transition-transform duration-200 ease-out ${
|
||||||
|
isOpen ? 'translate-x-0' : 'translate-x-full'
|
||||||
|
}`}
|
||||||
|
aria-label='Log details sidebar'
|
||||||
|
>
|
||||||
|
{log && (
|
||||||
|
<div className='flex h-full flex-col px-[14px] pt-[12px]'>
|
||||||
|
{/* Header */}
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<h2 className='font-medium text-[14px] text-[var(--text-primary)]'>Log Details</h2>
|
||||||
|
<div className='flex items-center gap-[1px]'>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
className='!p-[4px]'
|
||||||
|
onClick={() => hasPrev && handleNavigate(onNavigatePrev!)}
|
||||||
|
disabled={!hasPrev}
|
||||||
|
aria-label='Previous log'
|
||||||
|
>
|
||||||
|
<ChevronUp className='h-[14px] w-[14px] rotate-180' />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
className='!p-[4px]'
|
||||||
|
onClick={() => hasNext && handleNavigate(onNavigateNext!)}
|
||||||
|
disabled={!hasNext}
|
||||||
|
aria-label='Next log'
|
||||||
|
>
|
||||||
|
<ChevronUp className='h-[14px] w-[14px]' />
|
||||||
|
</Button>
|
||||||
|
<Button variant='ghost' className='!p-[4px]' onClick={onClose} aria-label='Close'>
|
||||||
|
<X className='h-[14px] w-[14px]' />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content - Scrollable */}
|
||||||
|
<ScrollArea className='mt-[20px] h-full w-full overflow-y-auto' ref={scrollAreaRef}>
|
||||||
|
<div className='flex flex-col gap-[10px] pb-[16px]'>
|
||||||
|
{/* Timestamp & Workflow Row */}
|
||||||
|
<div className='flex items-center gap-[16px] px-[1px]'>
|
||||||
|
{/* Timestamp Card */}
|
||||||
|
<div className='flex w-[140px] flex-col gap-[8px]'>
|
||||||
|
<div className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||||
|
Timestamp
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center gap-[6px]'>
|
||||||
|
<span className='font-medium text-[14px] text-[var(--text-secondary)]'>
|
||||||
|
{formattedTimestamp?.compactDate || 'N/A'}
|
||||||
|
</span>
|
||||||
|
<span className='font-medium text-[14px] text-[var(--text-secondary)]'>
|
||||||
|
{formattedTimestamp?.compactTime || 'N/A'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Workflow Card */}
|
||||||
|
{log.workflow && (
|
||||||
|
<div className='flex flex-col gap-[8px]'>
|
||||||
|
<div className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||||
|
Workflow
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center gap-[8px]'>
|
||||||
|
<div
|
||||||
|
className='h-[10px] w-[10px] flex-shrink-0 rounded-[3px]'
|
||||||
|
style={{ backgroundColor: log.workflow?.color }}
|
||||||
|
/>
|
||||||
|
<span className='font-medium text-[14px] text-[var(--text-secondary)]'>
|
||||||
|
{log.workflow.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Execution ID */}
|
||||||
|
{log.executionId && (
|
||||||
|
<div className='flex flex-col gap-[6px] rounded-[6px] bg-[var(--surface-2)] px-[10px] py-[8px]'>
|
||||||
|
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||||
|
Execution ID
|
||||||
|
</span>
|
||||||
|
<span className='truncate font-medium text-[14px] text-[var(--text-secondary)]'>
|
||||||
|
{log.executionId}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Details Section */}
|
||||||
|
<div className='flex flex-col'>
|
||||||
|
{/* Level */}
|
||||||
|
<div className='flex h-[48px] items-center justify-between border-[var(--border)] border-b p-[8px]'>
|
||||||
|
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>Level</span>
|
||||||
|
<StatusBadge status={logStatus} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Trigger */}
|
||||||
|
<div className='flex h-[48px] items-center justify-between border-[var(--border)] border-b p-[8px]'>
|
||||||
|
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||||
|
Trigger
|
||||||
|
</span>
|
||||||
|
{log.trigger ? (
|
||||||
|
<TriggerBadge trigger={log.trigger} />
|
||||||
|
) : (
|
||||||
|
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>—</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Duration */}
|
||||||
|
<div className='flex h-[48px] items-center justify-between p-[8px]'>
|
||||||
|
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||||
|
Duration
|
||||||
|
</span>
|
||||||
|
<span className='font-medium text-[14px] text-[var(--text-secondary)]'>
|
||||||
|
{log.duration || '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Workflow State */}
|
||||||
|
{isWorkflowExecutionLog && log.executionId && (
|
||||||
|
<div className='flex flex-col gap-[6px] rounded-[6px] bg-[var(--surface-2)] px-[10px] py-[8px]'>
|
||||||
|
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||||
|
Workflow State
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsFrozenCanvasOpen(true)}
|
||||||
|
className='flex items-center justify-between rounded-[6px] bg-[var(--surface-1)] px-[10px] py-[8px] transition-colors hover:bg-[var(--c-2A2A2A)]'
|
||||||
|
>
|
||||||
|
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
|
||||||
|
View Snapshot
|
||||||
|
</span>
|
||||||
|
<Eye className='h-[14px] w-[14px] text-[var(--text-subtle)]' />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Workflow Execution - Trace Spans */}
|
||||||
|
{isWorkflowExecutionLog && log.executionData?.traceSpans && (
|
||||||
|
<TraceSpans
|
||||||
|
traceSpans={log.executionData.traceSpans}
|
||||||
|
totalDuration={log.executionData.totalDuration}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Files */}
|
||||||
|
{log.files && log.files.length > 0 && <FileCards files={log.files} isExecutionFile />}
|
||||||
|
|
||||||
|
{/* Cost Breakdown */}
|
||||||
|
{hasCostInfo && (
|
||||||
|
<div className='flex flex-col gap-[8px]'>
|
||||||
|
<span className='px-[1px] font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||||
|
Cost Breakdown
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className='flex flex-col gap-[4px] rounded-[6px] border border-[var(--border)]'>
|
||||||
|
<div className='flex flex-col gap-[10px] rounded-[6px] p-[10px]'>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||||
|
Base Execution:
|
||||||
|
</span>
|
||||||
|
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
|
||||||
|
{formatCost(BASE_EXECUTION_CHARGE)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||||
|
Model Input:
|
||||||
|
</span>
|
||||||
|
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
|
||||||
|
{formatCost(log.cost?.input || 0)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||||
|
Model Output:
|
||||||
|
</span>
|
||||||
|
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
|
||||||
|
{formatCost(log.cost?.output || 0)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='border-[var(--border)] border-t' />
|
||||||
|
|
||||||
|
<div className='flex flex-col gap-[10px] rounded-[6px] p-[10px]'>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||||
|
Total:
|
||||||
|
</span>
|
||||||
|
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
|
||||||
|
{formatCost(log.cost?.total || 0)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<span className='font-medium text-[12px] text-[var(--text-tertiary)]'>
|
||||||
|
Tokens:
|
||||||
|
</span>
|
||||||
|
<span className='font-medium text-[12px] text-[var(--text-secondary)]'>
|
||||||
|
{log.cost?.tokens?.prompt || 0} in / {log.cost?.tokens?.completion || 0}{' '}
|
||||||
|
out
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex items-center justify-center rounded-[6px] bg-[var(--surface-2)] p-[8px] text-center'>
|
||||||
|
<p className='font-medium text-[11px] text-[var(--text-subtle)]'>
|
||||||
|
Total cost includes a base execution charge of{' '}
|
||||||
|
{formatCost(BASE_EXECUTION_CHARGE)} plus any model usage costs.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Frozen Canvas Modal */}
|
||||||
|
{log?.executionId && (
|
||||||
|
<FrozenCanvas
|
||||||
|
executionId={log.executionId}
|
||||||
|
traceSpans={log.executionData?.traceSpans}
|
||||||
|
isModal
|
||||||
|
isOpen={isFrozenCanvasOpen}
|
||||||
|
onClose={() => setIsFrozenCanvasOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ReactNode } from 'react'
|
import { type ReactNode, useState } from 'react'
|
||||||
import { ArrowUp, Bell, Loader2, RefreshCw, Search } from 'lucide-react'
|
import { ArrowUp, Bell, ChevronDown, Loader2, RefreshCw, Search } from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Popover,
|
Popover,
|
||||||
@@ -13,7 +13,83 @@ import { MoreHorizontal } from '@/components/emcn/icons'
|
|||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { cn } from '@/lib/core/utils/cn'
|
import { cn } from '@/lib/core/utils/cn'
|
||||||
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
import { soehne } from '@/app/_styles/fonts/soehne/soehne'
|
||||||
import Timeline from '@/app/workspace/[workspaceId]/logs/components/filters/components/timeline'
|
import { useFilterStore } from '@/stores/logs/filters/store'
|
||||||
|
import type { TimeRange } from '@/stores/logs/filters/types'
|
||||||
|
|
||||||
|
const FILTER_BUTTON_CLASS =
|
||||||
|
'w-full justify-between rounded-[10px] border-[#E5E5E5] bg-[var(--white)] font-normal text-sm dark:border-[#414141] dark:bg-[var(--surface-elevated)]'
|
||||||
|
|
||||||
|
type TimelineProps = {
|
||||||
|
variant?: 'default' | 'header'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Timeline component for time range selection.
|
||||||
|
* Displays a dropdown with predefined time ranges.
|
||||||
|
* @param props - The component props
|
||||||
|
* @returns Time range selector dropdown
|
||||||
|
*/
|
||||||
|
function Timeline({ variant = 'default' }: TimelineProps = {}) {
|
||||||
|
const { timeRange, setTimeRange } = useFilterStore()
|
||||||
|
const [isPopoverOpen, setIsPopoverOpen] = useState(false)
|
||||||
|
|
||||||
|
const specificTimeRanges: TimeRange[] = [
|
||||||
|
'Past 30 minutes',
|
||||||
|
'Past hour',
|
||||||
|
'Past 6 hours',
|
||||||
|
'Past 12 hours',
|
||||||
|
'Past 24 hours',
|
||||||
|
'Past 3 days',
|
||||||
|
'Past 7 days',
|
||||||
|
'Past 14 days',
|
||||||
|
'Past 30 days',
|
||||||
|
]
|
||||||
|
|
||||||
|
const handleTimeRangeSelect = (range: TimeRange) => {
|
||||||
|
setTimeRange(range)
|
||||||
|
setIsPopoverOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant='outline' className={FILTER_BUTTON_CLASS}>
|
||||||
|
{timeRange}
|
||||||
|
<ChevronDown className='ml-2 h-4 w-4 text-muted-foreground' />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
align={variant === 'header' ? 'end' : 'start'}
|
||||||
|
side='bottom'
|
||||||
|
sideOffset={4}
|
||||||
|
maxHeight={144}
|
||||||
|
>
|
||||||
|
<PopoverScrollArea>
|
||||||
|
<PopoverItem
|
||||||
|
active={timeRange === 'All time'}
|
||||||
|
showCheck
|
||||||
|
onClick={() => handleTimeRangeSelect('All time')}
|
||||||
|
>
|
||||||
|
All time
|
||||||
|
</PopoverItem>
|
||||||
|
|
||||||
|
<div className='my-[2px] h-px bg-[var(--surface-11)]' />
|
||||||
|
|
||||||
|
{specificTimeRanges.map((range) => (
|
||||||
|
<PopoverItem
|
||||||
|
key={range}
|
||||||
|
active={timeRange === range}
|
||||||
|
showCheck
|
||||||
|
onClick={() => handleTimeRangeSelect(range)}
|
||||||
|
>
|
||||||
|
{range}
|
||||||
|
</PopoverItem>
|
||||||
|
))}
|
||||||
|
</PopoverScrollArea>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
interface ControlsProps {
|
interface ControlsProps {
|
||||||
searchQuery?: string
|
searchQuery?: string
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { Controls, default } from './controls'
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default, SlackChannelSelector } from './slack-channel-selector'
|
||||||
@@ -81,8 +81,8 @@ export function SlackChannelSelector({
|
|||||||
|
|
||||||
if (!accountId) {
|
if (!accountId) {
|
||||||
return (
|
return (
|
||||||
<div className='rounded-[8px] border border-dashed p-3 text-center'>
|
<div className='rounded-[6px] border bg-[var(--surface-3)] p-[10px] text-center'>
|
||||||
<p className='text-muted-foreground text-sm'>Select a Slack account first</p>
|
<p className='text-[12px] text-[var(--text-muted)]'>Select a Slack account first</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -93,7 +93,7 @@ export function SlackChannelSelector({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='space-y-1'>
|
<div className='flex flex-col gap-[4px]'>
|
||||||
<Combobox
|
<Combobox
|
||||||
options={options}
|
options={options}
|
||||||
value={value}
|
value={value}
|
||||||
@@ -106,11 +106,13 @@ export function SlackChannelSelector({
|
|||||||
error={fetchError}
|
error={fetchError}
|
||||||
/>
|
/>
|
||||||
{selectedChannel && !fetchError && (
|
{selectedChannel && !fetchError && (
|
||||||
<p className='text-muted-foreground text-xs'>
|
<p className='text-[12px] text-[var(--text-muted)]'>
|
||||||
{selectedChannel.isPrivate ? 'Private' : 'Public'} channel: #{selectedChannel.name}
|
{selectedChannel.isPrivate ? 'Private' : 'Public'} channel: #{selectedChannel.name}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{error && <p className='text-red-400 text-xs'>{error}</p>}
|
{error && <p className='text-[11px] text-[var(--text-error)]'>{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default SlackChannelSelector
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default, WorkflowSelector } from './workflow-selector'
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { Layers, X } from 'lucide-react'
|
import { X } from 'lucide-react'
|
||||||
import { Button, Combobox, type ComboboxOption } from '@/components/emcn'
|
import { Badge, Combobox, type ComboboxOption } from '@/components/emcn'
|
||||||
import { Label, Skeleton } from '@/components/ui'
|
import { Skeleton } from '@/components/ui'
|
||||||
|
|
||||||
interface WorkflowSelectorProps {
|
interface WorkflowSelectorProps {
|
||||||
workspaceId: string
|
workspaceId: string
|
||||||
@@ -13,10 +13,10 @@ interface WorkflowSelectorProps {
|
|||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALL_WORKFLOWS_VALUE = '__all_workflows__'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Multi-select workflow selector with "All Workflows" option.
|
* Multi-select workflow selector with "All Workflows" option.
|
||||||
|
* Uses Combobox's built-in showAllOption for the "All Workflows" selection.
|
||||||
|
* When allWorkflows is true, the array is empty and "All Workflows" is selected.
|
||||||
*/
|
*/
|
||||||
export function WorkflowSelector({
|
export function WorkflowSelector({
|
||||||
workspaceId,
|
workspaceId,
|
||||||
@@ -47,83 +47,48 @@ export function WorkflowSelector({
|
|||||||
}, [workspaceId])
|
}, [workspaceId])
|
||||||
|
|
||||||
const options: ComboboxOption[] = useMemo(() => {
|
const options: ComboboxOption[] = useMemo(() => {
|
||||||
const workflowOptions = workflows.map((w) => ({
|
return workflows.map((w) => ({
|
||||||
label: w.name,
|
label: w.name,
|
||||||
value: w.id,
|
value: w.id,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
label: 'All Workflows',
|
|
||||||
value: ALL_WORKFLOWS_VALUE,
|
|
||||||
icon: Layers,
|
|
||||||
},
|
|
||||||
...workflowOptions,
|
|
||||||
]
|
|
||||||
}, [workflows])
|
}, [workflows])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When allWorkflows is true, pass empty array so the "All" option is selected.
|
||||||
|
* Otherwise, pass the selected workflow IDs.
|
||||||
|
*/
|
||||||
const currentValues = useMemo(() => {
|
const currentValues = useMemo(() => {
|
||||||
if (allWorkflows) {
|
return allWorkflows ? [] : selectedIds
|
||||||
return [ALL_WORKFLOWS_VALUE]
|
|
||||||
}
|
|
||||||
return selectedIds
|
|
||||||
}, [allWorkflows, selectedIds])
|
}, [allWorkflows, selectedIds])
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle multi-select changes from Combobox.
|
||||||
|
* Empty array from showAllOption = all workflows selected.
|
||||||
|
*/
|
||||||
const handleMultiSelectChange = (values: string[]) => {
|
const handleMultiSelectChange = (values: string[]) => {
|
||||||
const hasAllWorkflows = values.includes(ALL_WORKFLOWS_VALUE)
|
if (values.length === 0) {
|
||||||
const hadAllWorkflows = allWorkflows
|
|
||||||
|
|
||||||
if (hasAllWorkflows && !hadAllWorkflows) {
|
|
||||||
// User selected "All Workflows" - clear individual selections
|
|
||||||
onChange([], true)
|
onChange([], true)
|
||||||
} else if (!hasAllWorkflows && hadAllWorkflows) {
|
|
||||||
// User deselected "All Workflows" - switch to individual selection
|
|
||||||
onChange(
|
|
||||||
values.filter((v) => v !== ALL_WORKFLOWS_VALUE),
|
|
||||||
false
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
// Normal individual workflow selection/deselection
|
onChange(values, false)
|
||||||
onChange(
|
|
||||||
values.filter((v) => v !== ALL_WORKFLOWS_VALUE),
|
|
||||||
false
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRemove = (e: React.MouseEvent, id: string) => {
|
const handleRemove = (e: React.MouseEvent, id: string) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
if (id === ALL_WORKFLOWS_VALUE) {
|
onChange(
|
||||||
onChange([], false)
|
selectedIds.filter((i) => i !== id),
|
||||||
} else {
|
false
|
||||||
onChange(
|
)
|
||||||
selectedIds.filter((i) => i !== id),
|
|
||||||
false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedWorkflows = useMemo(() => {
|
const selectedWorkflows = useMemo(() => {
|
||||||
return workflows.filter((w) => selectedIds.includes(w.id))
|
return workflows.filter((w) => selectedIds.includes(w.id))
|
||||||
}, [workflows, selectedIds])
|
}, [workflows, selectedIds])
|
||||||
|
|
||||||
// Render overlay content showing selected items as tags
|
|
||||||
const overlayContent = useMemo(() => {
|
const overlayContent = useMemo(() => {
|
||||||
if (allWorkflows) {
|
if (allWorkflows) {
|
||||||
return (
|
return <span className='truncate text-[var(--text-primary)]'>All Workflows</span>
|
||||||
<div className='flex items-center gap-1'>
|
|
||||||
<Button
|
|
||||||
variant='outline'
|
|
||||||
className='pointer-events-auto h-6 gap-1 rounded-[6px] px-2 text-[11px]'
|
|
||||||
onMouseDown={(e) => handleRemove(e, ALL_WORKFLOWS_VALUE)}
|
|
||||||
>
|
|
||||||
<Layers className='h-3 w-3' />
|
|
||||||
All Workflows
|
|
||||||
<X className='h-3 w-3' />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedWorkflows.length === 0) {
|
if (selectedWorkflows.length === 0) {
|
||||||
@@ -131,22 +96,22 @@ export function WorkflowSelector({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex items-center gap-1 overflow-hidden'>
|
<div className='flex items-center gap-[4px] overflow-hidden'>
|
||||||
{selectedWorkflows.slice(0, 2).map((w) => (
|
{selectedWorkflows.slice(0, 2).map((w) => (
|
||||||
<Button
|
<Badge
|
||||||
key={w.id}
|
key={w.id}
|
||||||
variant='outline'
|
variant='outline'
|
||||||
className='pointer-events-auto h-6 gap-1 rounded-[6px] px-2 text-[11px]'
|
className='pointer-events-auto cursor-pointer gap-[4px] rounded-[6px] px-[8px] py-[2px] text-[11px]'
|
||||||
onMouseDown={(e) => handleRemove(e, w.id)}
|
onMouseDown={(e) => handleRemove(e, w.id)}
|
||||||
>
|
>
|
||||||
{w.name}
|
{w.name}
|
||||||
<X className='h-3 w-3' />
|
<X className='h-3 w-3' />
|
||||||
</Button>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
{selectedWorkflows.length > 2 && (
|
{selectedWorkflows.length > 2 && (
|
||||||
<span className='flex h-6 items-center rounded-[6px] border px-2 text-[11px]'>
|
<Badge variant='outline' className='rounded-[6px] px-[8px] py-[2px] text-[11px]'>
|
||||||
+{selectedWorkflows.length - 2}
|
+{selectedWorkflows.length - 2}
|
||||||
</span>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -154,16 +119,16 @@ export function WorkflowSelector({
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className='space-y-2'>
|
<div className='flex flex-col gap-[4px]'>
|
||||||
<Label className='font-medium text-sm'>Workflows</Label>
|
<span className='font-medium text-[13px] text-[var(--text-secondary)]'>Workflows</span>
|
||||||
<Skeleton className='h-9 w-full rounded-[4px]' />
|
<Skeleton className='h-[34px] w-full rounded-[6px]' />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='space-y-2'>
|
<div className='flex flex-col gap-[4px]'>
|
||||||
<Label className='font-medium text-sm'>Workflows</Label>
|
<span className='font-medium text-[13px] text-[var(--text-secondary)]'>Workflows</span>
|
||||||
<Combobox
|
<Combobox
|
||||||
options={options}
|
options={options}
|
||||||
multiSelect
|
multiSelect
|
||||||
@@ -174,10 +139,11 @@ export function WorkflowSelector({
|
|||||||
overlayContent={overlayContent}
|
overlayContent={overlayContent}
|
||||||
searchable
|
searchable
|
||||||
searchPlaceholder='Search workflows...'
|
searchPlaceholder='Search workflows...'
|
||||||
|
showAllOption
|
||||||
|
allOptionLabel='All Workflows'
|
||||||
/>
|
/>
|
||||||
<p className='text-muted-foreground text-xs'>
|
|
||||||
Select which workflows should trigger this notification
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default WorkflowSelector
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { NotificationSettings } from './notifications'
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
export { AutocompleteSearch } from './search'
|
||||||
@@ -31,7 +31,7 @@ interface AutocompleteSearchProps {
|
|||||||
export function AutocompleteSearch({
|
export function AutocompleteSearch({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
placeholder = 'Search logs...',
|
placeholder = 'Search',
|
||||||
className,
|
className,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: AutocompleteSearchProps) {
|
}: AutocompleteSearchProps) {
|
||||||
@@ -139,11 +139,11 @@ export function AutocompleteSearch({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const [dropdownWidth, setDropdownWidth] = useState(500)
|
const [dropdownWidth, setDropdownWidth] = useState(400)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const measure = () => {
|
const measure = () => {
|
||||||
if (inputRef.current) {
|
if (inputRef.current) {
|
||||||
setDropdownWidth(inputRef.current.parentElement?.offsetWidth || 500)
|
setDropdownWidth(inputRef.current.parentElement?.offsetWidth || 400)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
measure()
|
measure()
|
||||||
@@ -181,15 +181,12 @@ export function AutocompleteSearch({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PopoverAnchor asChild>
|
<PopoverAnchor asChild>
|
||||||
<div className='relative flex h-9 w-[500px] items-center rounded-[4px] border border-[var(--surface-11)] bg-[var(--surface-6)] transition-colors focus-within:border-[var(--surface-14)] focus-within:ring-1 focus-within:ring-ring hover:border-[var(--surface-14)] dark:bg-[var(--surface-9)] dark:hover:border-[var(--surface-13)]'>
|
<div className='relative flex h-[32px] w-[400px] items-center rounded-[8px] bg-[var(--surface-5)]'>
|
||||||
{/* Search Icon */}
|
{/* Search Icon */}
|
||||||
<Search
|
<Search className='mr-[6px] ml-[8px] h-[14px] w-[14px] flex-shrink-0 text-[var(--text-subtle)]' />
|
||||||
className='ml-2.5 h-4 w-4 flex-shrink-0 text-muted-foreground'
|
|
||||||
strokeWidth={2}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Scrollable container for badges */}
|
{/* Scrollable container for badges */}
|
||||||
<div className='flex flex-1 items-center gap-1.5 overflow-x-auto px-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'>
|
<div className='flex flex-1 items-center gap-[6px] overflow-x-auto pr-[6px] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden'>
|
||||||
{/* Applied Filter Badges */}
|
{/* Applied Filter Badges */}
|
||||||
{appliedFilters.map((filter, index) => (
|
{appliedFilters.map((filter, index) => (
|
||||||
<Button
|
<Button
|
||||||
@@ -197,7 +194,7 @@ export function AutocompleteSearch({
|
|||||||
variant='outline'
|
variant='outline'
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-6 flex-shrink-0 gap-1 rounded-[6px] px-2 text-[11px]',
|
'h-6 flex-shrink-0 gap-1 rounded-[6px] px-2 text-[11px]',
|
||||||
highlightedBadgeIndex === index && 'border-white dark:border-white'
|
highlightedBadgeIndex === index && 'border'
|
||||||
)}
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -238,7 +235,7 @@ export function AutocompleteSearch({
|
|||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
className='min-w-[100px] flex-1 border-0 bg-transparent font-sans text-foreground text-sm outline-none placeholder:text-[var(--text-muted)]'
|
className='min-w-[80px] flex-1 border-0 bg-transparent px-0 font-medium text-[var(--text-secondary)] text-small leading-none outline-none placeholder:text-[var(--text-subtle)] focus-visible:ring-0 focus-visible:ring-offset-0 md:text-sm'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -246,10 +243,10 @@ export function AutocompleteSearch({
|
|||||||
{(hasFilters || hasTextSearch) && (
|
{(hasFilters || hasTextSearch) && (
|
||||||
<button
|
<button
|
||||||
type='button'
|
type='button'
|
||||||
className='mr-2.5 flex h-5 w-5 flex-shrink-0 items-center justify-center text-muted-foreground transition-colors hover:text-foreground'
|
className='mr-[8px] ml-[6px] flex h-[14px] w-[14px] flex-shrink-0 items-center justify-center text-[var(--text-subtle)] transition-colors hover:text-[var(--text-secondary)]'
|
||||||
onClick={clearAll}
|
onClick={clearAll}
|
||||||
>
|
>
|
||||||
<X className='h-4 w-4' />
|
<X className='h-[14px] w-[14px]' />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -290,7 +287,7 @@ export function AutocompleteSearch({
|
|||||||
|
|
||||||
{sections.map((section) => (
|
{sections.map((section) => (
|
||||||
<div key={section.title}>
|
<div key={section.title}>
|
||||||
<div className='border-border/50 border-t px-3 py-1.5 font-medium text-[11px] text-[var(--text-muted)] uppercase tracking-wide'>
|
<div className='border-[var(--divider)] border-t px-3 py-1.5 font-medium text-[11px] text-[var(--text-tertiary)] uppercase tracking-wide'>
|
||||||
{section.title}
|
{section.title}
|
||||||
</div>
|
</div>
|
||||||
{section.suggestions.map((suggestion) => {
|
{section.suggestions.map((suggestion) => {
|
||||||
@@ -345,7 +342,7 @@ export function AutocompleteSearch({
|
|||||||
// Single section layout
|
// Single section layout
|
||||||
<div className='py-1'>
|
<div className='py-1'>
|
||||||
{suggestionType === 'filters' && (
|
{suggestionType === 'filters' && (
|
||||||
<div className='border-border/50 border-b px-3 py-1.5 font-medium text-[11px] text-[var(--text-muted)] uppercase tracking-wide'>
|
<div className='border-[var(--divider)] border-b px-3 py-1.5 font-medium text-[11px] text-[var(--text-tertiary)] uppercase tracking-wide'>
|
||||||
SUGGESTED FILTERS
|
SUGGESTED FILTERS
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export { Controls } from './components/controls'
|
||||||
|
export { NotificationSettings } from './components/notifications'
|
||||||
|
export { AutocompleteSearch } from './components/search'
|
||||||
|
export { LogsToolbar } from './logs-toolbar'
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user