mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
improvements(knowledge): ui/ux
This commit is contained in:
@@ -3,7 +3,7 @@ import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { createLogger } from '@/lib/logs/console-logger'
|
||||
import { getS3Client } from '@/lib/uploads/s3-client'
|
||||
import { getS3Client, sanitizeFilenameForMetadata } from '@/lib/uploads/s3-client'
|
||||
import { S3_CONFIG, USE_S3_STORAGE } from '@/lib/uploads/setup'
|
||||
import { createErrorResponse, createOptionsResponse } from '../utils'
|
||||
|
||||
@@ -40,6 +40,9 @@ export async function POST(request: NextRequest) {
|
||||
const safeFileName = fileName.replace(/\s+/g, '-')
|
||||
const uniqueKey = `${Date.now()}-${uuidv4()}-${safeFileName}`
|
||||
|
||||
// Sanitize the original filename for S3 metadata to prevent header errors
|
||||
const sanitizedOriginalName = sanitizeFilenameForMetadata(fileName)
|
||||
|
||||
// Create the S3 command
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: S3_CONFIG.bucket,
|
||||
|
||||
@@ -47,7 +47,7 @@ export function DocumentLoading({
|
||||
<div className='flex flex-1 overflow-hidden'>
|
||||
<div className='flex flex-1 flex-col overflow-hidden'>
|
||||
{/* Main Content */}
|
||||
<div className='flex-1 overflow-auto pt-[4px]'>
|
||||
<div className='flex-1 overflow-auto'>
|
||||
<div className='px-6 pb-6'>
|
||||
{/* Search Section */}
|
||||
<div className='mb-4'>
|
||||
|
||||
@@ -649,6 +649,9 @@ export function KnowledgeBase({
|
||||
<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'>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
|
||||
@@ -718,6 +721,11 @@ export function KnowledgeBase({
|
||||
<div className='text-muted-foreground text-xs'>—</div>
|
||||
</td>
|
||||
|
||||
{/* Processing column */}
|
||||
<td className='px-4 py-3'>
|
||||
<div className='text-muted-foreground text-xs'>—</div>
|
||||
</td>
|
||||
|
||||
{/* Status column */}
|
||||
<td className='px-4 py-3'>
|
||||
<div className='text-muted-foreground text-xs'>—</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { LibraryBig, Search } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { Search } from 'lucide-react'
|
||||
import { useSidebarStore } from '@/stores/sidebar/store'
|
||||
import { KnowledgeHeader } from '../../components/knowledge-header/knowledge-header'
|
||||
import { DocumentTableSkeleton } from '../../components/skeletons/table-skeleton'
|
||||
|
||||
interface KnowledgeBaseLoadingProps {
|
||||
@@ -14,28 +14,29 @@ export function KnowledgeBaseLoading({ knowledgeBaseName }: KnowledgeBaseLoading
|
||||
const isSidebarCollapsed =
|
||||
mode === 'expanded' ? !isExpanded : mode === 'collapsed' || mode === 'hover'
|
||||
|
||||
const breadcrumbs = [
|
||||
{
|
||||
id: 'knowledge-root',
|
||||
label: 'Knowledge',
|
||||
href: '/w/knowledge',
|
||||
},
|
||||
{
|
||||
id: 'knowledge-base-loading',
|
||||
label: knowledgeBaseName,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex h-[100vh] flex-col transition-padding duration-200 ${isSidebarCollapsed ? 'pl-14' : 'pl-60'}`}
|
||||
>
|
||||
{/* Fixed Header with Breadcrumbs */}
|
||||
<div className='flex items-center gap-2 px-6 pt-[14px] pb-6'>
|
||||
<Link
|
||||
href='/w/knowledge'
|
||||
prefetch={true}
|
||||
className='group flex items-center gap-2 font-medium text-sm transition-colors hover:text-muted-foreground'
|
||||
>
|
||||
<LibraryBig className='h-[18px] w-[18px] text-muted-foreground transition-colors group-hover:text-muted-foreground/70' />
|
||||
<span>Knowledge</span>
|
||||
</Link>
|
||||
<span className='text-muted-foreground'>/</span>
|
||||
<span className='font-medium text-sm'>{knowledgeBaseName}</span>
|
||||
</div>
|
||||
<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 pt-[4px]'>
|
||||
<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'>
|
||||
@@ -51,13 +52,16 @@ export function KnowledgeBaseLoading({ knowledgeBaseName }: KnowledgeBaseLoading
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* <button
|
||||
disabled
|
||||
className='flex items-center gap-1 rounded-md bg-[#701FFC] px-3 py-[7px] font-[480] text-primary-foreground text-sm shadow-[0_0_0_0_#701FFC] transition-all duration-200 hover:bg-[#6518E6] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)] disabled:opacity-50'
|
||||
>
|
||||
<Plus className='h-4 w-4 font-[480]' />
|
||||
<span>Add Document</span>
|
||||
</button> */}
|
||||
<div className='flex items-center gap-3'>
|
||||
{/* Add Documents Button - disabled state */}
|
||||
<button
|
||||
disabled
|
||||
className='mt-1 mr-1 flex items-center gap-1.5 rounded-md bg-[#701FFC] px-3 py-[7px] font-[480] text-primary-foreground text-sm shadow-[0_0_0_0_#701FFC] transition-all duration-200 hover:bg-[#6518E6] hover:shadow-[0_0_0_3px_rgba(127,47,255,0.12)] disabled:opacity-50'
|
||||
>
|
||||
<div className='h-3.5 w-3.5 animate-pulse rounded bg-primary-foreground/30' />
|
||||
<span>Add Documents</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table container */}
|
||||
|
||||
@@ -23,6 +23,8 @@ const HEADER_STYLES = {
|
||||
link: 'group flex items-center gap-2 font-medium text-sm transition-colors hover:text-muted-foreground',
|
||||
label: 'font-medium text-sm',
|
||||
separator: 'text-muted-foreground',
|
||||
// Always reserve consistent space for actions area
|
||||
actionsContainer: 'flex h-8 w-8 items-center justify-center',
|
||||
} as const
|
||||
|
||||
interface KnowledgeHeaderOptions {
|
||||
@@ -60,30 +62,32 @@ export function KnowledgeHeader({ breadcrumbs, options }: KnowledgeHeaderProps)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Actions Menu - only show if onDeleteKnowledgeBase is provided */}
|
||||
{options?.onDeleteKnowledgeBase && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-8 w-8 p-0'
|
||||
aria-label='Knowledge base actions menu'
|
||||
>
|
||||
<MoreHorizontal className='h-4 w-4' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuItem
|
||||
onClick={options.onDeleteKnowledgeBase}
|
||||
className='text-red-600 focus:text-red-600'
|
||||
>
|
||||
<Trash2 className='mr-2 h-4 w-4' />
|
||||
Delete Knowledge Base
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
{/* Actions Area - always reserve consistent space */}
|
||||
<div className={HEADER_STYLES.actionsContainer}>
|
||||
{options?.onDeleteKnowledgeBase && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-8 w-8 p-0'
|
||||
aria-label='Knowledge base actions menu'
|
||||
>
|
||||
<MoreHorizontal className='h-4 w-4' />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuItem
|
||||
onClick={options.onDeleteKnowledgeBase}
|
||||
className='text-red-600 focus:text-red-600'
|
||||
>
|
||||
<Trash2 className='mr-2 h-4 w-4' />
|
||||
Delete Knowledge Base
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -37,7 +37,12 @@ export function DocumentTableRowSkeleton({ isSidebarCollapsed }: { isSidebarColl
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Status column */}
|
||||
{/* Processing Status column */}
|
||||
<td className='px-4 py-3'>
|
||||
<div className='h-6 w-16 animate-pulse rounded-md bg-muted' />
|
||||
</td>
|
||||
|
||||
{/* Active Status column */}
|
||||
<td className='px-4 py-3'>
|
||||
<div className='h-6 w-16 animate-pulse rounded-md bg-muted' />
|
||||
</td>
|
||||
@@ -47,6 +52,7 @@ export function DocumentTableRowSkeleton({ isSidebarCollapsed }: { isSidebarColl
|
||||
<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>
|
||||
@@ -106,17 +112,18 @@ export function DocumentTableSkeleton({
|
||||
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'>
|
||||
<div className='sticky top-0 z-10 overflow-x-auto border-b bg-background'>
|
||||
<table className='w-full min-w-[800px] table-fixed'>
|
||||
<colgroup>
|
||||
<col className='w-[5%]' />
|
||||
<col className={`${isSidebarCollapsed ? 'w-[18%]' : 'w-[20%]'}`} />
|
||||
<col className='w-[10%]' />
|
||||
<col className='w-[10%]' />
|
||||
<col className='w-[4%]' />
|
||||
<col className={`${isSidebarCollapsed ? 'w-[20%]' : 'w-[22%]'}`} />
|
||||
<col className='w-[8%]' />
|
||||
<col className='w-[8%]' />
|
||||
<col className='hidden w-[8%] lg:table-column' />
|
||||
<col className={`${isSidebarCollapsed ? 'w-[22%]' : 'w-[20%]'}`} />
|
||||
<col className={`${isSidebarCollapsed ? 'w-[16%]' : 'w-[14%]'}`} />
|
||||
<col className='w-[10%]' />
|
||||
<col className='w-[16%]' />
|
||||
<col className='w-[10%]' />
|
||||
<col className='w-[12%]' />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -138,6 +145,9 @@ export function DocumentTableSkeleton({
|
||||
<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'>Processing</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>
|
||||
@@ -151,16 +161,17 @@ export function DocumentTableSkeleton({
|
||||
|
||||
{/* Table body - scrollable */}
|
||||
<div className='flex-1 overflow-auto'>
|
||||
<table className='w-full table-fixed'>
|
||||
<table className='w-full min-w-[800px] table-fixed'>
|
||||
<colgroup>
|
||||
<col className='w-[5%]' />
|
||||
<col className={`${isSidebarCollapsed ? 'w-[18%]' : 'w-[20%]'}`} />
|
||||
<col className='w-[10%]' />
|
||||
<col className='w-[10%]' />
|
||||
<col className='w-[4%]' />
|
||||
<col className={`${isSidebarCollapsed ? 'w-[20%]' : 'w-[22%]'}`} />
|
||||
<col className='w-[8%]' />
|
||||
<col className='w-[8%]' />
|
||||
<col className='hidden w-[8%] lg:table-column' />
|
||||
<col className={`${isSidebarCollapsed ? 'w-[22%]' : 'w-[20%]'}`} />
|
||||
<col className={`${isSidebarCollapsed ? 'w-[16%]' : 'w-[14%]'}`} />
|
||||
<col className='w-[10%]' />
|
||||
<col className='w-[16%]' />
|
||||
<col className='w-[10%]' />
|
||||
<col className='w-[12%]' />
|
||||
</colgroup>
|
||||
<tbody>
|
||||
{Array.from({ length: rowCount }).map((_, i) => (
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { LibraryBig, Plus, Search } from 'lucide-react'
|
||||
import { Plus, Search } from 'lucide-react'
|
||||
import { useSidebarStore } from '@/stores/sidebar/store'
|
||||
import { KnowledgeHeader } from './components/knowledge-header/knowledge-header'
|
||||
import { KnowledgeBaseCardSkeletonGrid } from './components/skeletons/knowledge-base-card-skeleton'
|
||||
|
||||
export default function KnowledgeLoading() {
|
||||
@@ -9,44 +10,47 @@ export default function KnowledgeLoading() {
|
||||
const isSidebarCollapsed =
|
||||
mode === 'expanded' ? !isExpanded : mode === 'collapsed' || mode === 'hover'
|
||||
|
||||
const breadcrumbs = [{ id: 'knowledge', label: 'Knowledge' }]
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed inset-0 flex flex-col transition-all duration-200 ${isSidebarCollapsed ? 'left-14' : 'left-60'}`}
|
||||
className={`flex h-screen flex-col transition-padding duration-200 ${isSidebarCollapsed ? 'pl-14' : 'pl-60'}`}
|
||||
>
|
||||
{/* Fixed Header */}
|
||||
<div className='flex items-center gap-2 px-6 pt-4 pb-6'>
|
||||
<LibraryBig className='h-[18px] w-[18px] text-muted-foreground' />
|
||||
<h1 className='font-medium text-sm'>Knowledge</h1>
|
||||
</div>
|
||||
{/* Header */}
|
||||
<KnowledgeHeader breadcrumbs={breadcrumbs} />
|
||||
|
||||
{/* Main Content */}
|
||||
<div className='flex-1 overflow-auto pt-[6px]'>
|
||||
<div className='px-6 pb-6'>
|
||||
{/* Search and Create Section */}
|
||||
<div className='mb-6 flex items-center justify-between'>
|
||||
<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 knowledge bases...'
|
||||
<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'>
|
||||
<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 knowledge bases...'
|
||||
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
|
||||
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'
|
||||
/>
|
||||
className='flex items-center gap-1 rounded-md bg-[#701FFC] px-3 py-[7px] font-[480] text-primary-foreground text-sm shadow-[0_0_0_0_#701FFC] transition-all duration-200 hover:bg-[#6518E6] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)] disabled:opacity-50'
|
||||
>
|
||||
<Plus className='h-4 w-4 font-[480]' />
|
||||
<span>Create</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<KnowledgeBaseCardSkeletonGrid count={8} />
|
||||
</div>
|
||||
|
||||
<button
|
||||
disabled
|
||||
className='flex items-center gap-1 rounded-md bg-[#701FFC] px-3 py-[7px] font-[480] text-primary-foreground text-sm shadow-[0_0_0_0_#701FFC] transition-all duration-200 hover:bg-[#6518E6] hover:shadow-[0_0_0_4px_rgba(127,47,255,0.15)] disabled:opacity-50'
|
||||
>
|
||||
<Plus className='h-4 w-4 font-[480]' />
|
||||
<span>Create</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<KnowledgeBaseCardSkeletonGrid count={8} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -33,6 +33,27 @@ export function getS3Client(): S3Client {
|
||||
return _s3Client
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a filename for use in S3 metadata headers
|
||||
* S3 metadata headers must contain only ASCII printable characters (0x20-0x7E)
|
||||
* and cannot contain certain special characters
|
||||
*/
|
||||
export function sanitizeFilenameForMetadata(filename: string): string {
|
||||
return (
|
||||
filename
|
||||
// Remove non-ASCII characters (keep only printable ASCII 0x20-0x7E)
|
||||
.replace(/[^\x20-\x7E]/g, '')
|
||||
// Remove characters that are problematic in HTTP headers
|
||||
.replace(/["\\]/g, '')
|
||||
// Replace multiple spaces with single space
|
||||
.replace(/\s+/g, ' ')
|
||||
// Trim whitespace
|
||||
.trim() ||
|
||||
// Provide fallback if completely sanitized
|
||||
'file'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* File information structure
|
||||
*/
|
||||
@@ -110,6 +131,12 @@ export async function uploadToS3(
|
||||
const safeFileName = fileName.replace(/\s+/g, '-') // Replace spaces with hyphens
|
||||
const uniqueKey = `${Date.now()}-${safeFileName}`
|
||||
|
||||
// Sanitize filename for S3 metadata (only allow ASCII printable characters)
|
||||
const sanitizedOriginalName = fileName
|
||||
.replace(/[^\x20-\x7E]/g, '') // Remove non-ASCII characters
|
||||
.replace(/["\\]/g, '') // Remove quotes and backslashes
|
||||
.trim()
|
||||
|
||||
const s3Client = getS3Client()
|
||||
|
||||
// Upload the file to S3
|
||||
@@ -119,7 +146,7 @@ export async function uploadToS3(
|
||||
Key: uniqueKey,
|
||||
Body: file,
|
||||
ContentType: contentType,
|
||||
// Add some useful metadata
|
||||
// Add some useful metadata with sanitized values
|
||||
Metadata: {
|
||||
originalName: encodeURIComponent(fileName), // Encode filename to prevent invalid characters in HTTP headers
|
||||
uploadedAt: new Date().toISOString(),
|
||||
@@ -133,7 +160,7 @@ export async function uploadToS3(
|
||||
return {
|
||||
path: servePath,
|
||||
key: uniqueKey,
|
||||
name: fileName,
|
||||
name: fileName, // Return the actual original filename in the response
|
||||
size: fileSize,
|
||||
type: contentType,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user