Files
kamidso-front/src/features/knowledge/CreateSpaceModal.tsx
T

520 lines
20 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from 'react'
import {
FileText,
FolderOpen,
Globe,
Loader2,
Search,
Settings2,
Type,
Upload,
X,
} from 'lucide-react'
import { Modal } from '../../components/Modal'
import { Button } from '../../components/Button'
import { Field, Input, LabeledInput, LabeledTextarea, Select } from '../../components/ui/Field'
import { Collapsible } from '../../components/ui/Collapsible'
import { FileTitleModal } from '../assistant/session-files/FileTitleModal'
import { listWorkspaceFiles, uploadWorkspaceFile, type KamidsoFile } from '../assistant/session-files'
import {
KNOWLEDGE_FILE_ACCEPT,
fileKindLine,
isKnowledgeFileKind,
isKnowledgeFilename,
} from '../assistant/session-files/fileKind'
import { formatBytes } from '../assistant/session-files/attachment-view-model'
import { t } from '../../i18n'
import type { KnowledgeChunkSettings, KnowledgeSourceInput } from '../../contracts/knowledge'
import type { SourceKind } from './types'
const SOURCE_OPTIONS: Array<{
id: SourceKind
icon: typeof Upload
labelKey: string
hintKey: string
}> = [
{ id: 'file', icon: Upload, labelKey: 'knowledge.sourceFile', hintKey: 'knowledge.sourceFileHint' },
{ id: 'library', icon: FolderOpen, labelKey: 'knowledge.sourceLibrary', hintKey: 'knowledge.sourceLibraryHint' },
{ id: 'url', icon: Globe, labelKey: 'knowledge.sourceUrl', hintKey: 'knowledge.sourceUrlHint' },
{ id: 'text', icon: Type, labelKey: 'knowledge.sourceText', hintKey: 'knowledge.sourceTextHint' },
]
export function CreateSpaceModal({
open,
title,
submitLabel,
variant = 'space',
initialKind,
onClose,
onSubmit,
}: {
open: boolean
title: string
submitLabel: string
variant?: 'space' | 'document'
initialKind?: SourceKind
onClose: () => void
onSubmit: (payload: {
name: string
description?: string
sources: KnowledgeSourceInput[]
chunk: KnowledgeChunkSettings
}) => Promise<void>
}) {
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [kind, setKind] = useState<SourceKind>('file')
const [url, setUrl] = useState('')
const [text, setText] = useState('')
const [textTitle, setTextTitle] = useState('')
const [library, setLibrary] = useState<KamidsoFile[]>([])
const [libraryQuery, setLibraryQuery] = useState('')
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [pendingUpload, setPendingUpload] = useState<File[] | null>(null)
const [uploaded, setUploaded] = useState<KamidsoFile[]>([])
const [dragging, setDragging] = useState(false)
const [chunkSize, setChunkSize] = useState('512')
const [chunkOverlap, setChunkOverlap] = useState('50')
const [strategy, setStrategy] = useState('Automatic')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (!open) return
setName('')
setDescription('')
setKind(initialKind || 'file')
setUrl('')
setText('')
setTextTitle('')
setLibraryQuery('')
setSelectedIds([])
setUploaded([])
setDragging(false)
setError(null)
setBusy(false)
void listWorkspaceFiles().then(setLibrary).catch(() => setLibrary([]))
}, [open, initialKind])
const knowledgeLibrary = useMemo(
() => library.filter((file) => isKnowledgeFileKind(file.kind)),
[library],
)
const selectedFiles = useMemo(
() => knowledgeLibrary.filter((file) => selectedIds.includes(file.id)),
[knowledgeLibrary, selectedIds],
)
const filteredLibrary = useMemo(() => {
const needle = libraryQuery.trim().toLowerCase()
if (!needle) return knowledgeLibrary
return knowledgeLibrary.filter((file) =>
`${file.title || ''} ${file.name} ${file.original_name || ''}`.toLowerCase().includes(needle),
)
}, [knowledgeLibrary, libraryQuery])
const sourceCount =
kind === 'url'
? url.trim()
? 1
: 0
: kind === 'text'
? text.trim()
? 1
: 0
: kind === 'library'
? selectedIds.length
: uploaded.length
const nameMissing = variant === 'space' && !name.trim()
const nameError = error && nameMissing ? error : null
const formError = error && !nameError ? error : null
async function confirmUpload(files: File[], titles: string[]) {
setPendingUpload(null)
try {
const next: KamidsoFile[] = []
for (let index = 0; index < files.length; index += 1) {
next.push(await uploadWorkspaceFile(files[index]!, titles[index]))
}
setUploaded((current) => [...current, ...next])
if (variant === 'space' && !name.trim() && next[0]) {
setName(next[0].title?.trim() || next[0].name)
}
} catch (err) {
setError(err instanceof Error ? err.message : t('knowledge.uploadFailed'))
}
}
function takeFiles(list: FileList | File[]) {
const files = Array.from(list).filter((file) => isKnowledgeFilename(file.name))
if (!files.length) {
setError(t('knowledge.fileHint'))
return
}
setError(null)
setPendingUpload(files)
}
function sources(): KnowledgeSourceInput[] {
if (kind === 'url') {
return url.trim() ? [{ type: 'url', url: url.trim(), title: name.trim() || url.trim() }] : []
}
if (kind === 'text') {
return text.trim()
? [{ type: 'text', text, title: textTitle.trim() || name.trim() || t('knowledge.textSource') }]
: []
}
const ids = kind === 'library' ? selectedIds : uploaded.map((file) => file.id)
return ids.map((file_id) => ({ type: 'file', file_id }))
}
async function submit() {
if (nameMissing) {
setError(t('knowledge.nameRequired'))
return
}
setBusy(true)
setError(null)
try {
await onSubmit({
name: name.trim(),
description: description.trim() || undefined,
sources: sources(),
chunk: {
strategy,
chunk_size: Number(chunkSize) || 512,
chunk_overlap: Number(chunkOverlap) || 50,
},
})
onClose()
} catch (err) {
setError(err instanceof Error ? err.message : t('knowledge.saveFailed'))
} finally {
setBusy(false)
}
}
return (
<>
<Modal
open={open}
title={title}
description={variant === 'space' ? t('knowledge.createHint') : t('knowledge.addDocumentHint')}
size="lg"
onClose={onClose}
footer={
<div className="flex w-full items-center justify-between gap-3">
<p className="min-w-0 text-[11px] leading-5 text-ink-muted">
{sourceCount > 0
? `${sourceCount} ${t('knowledge.selected')}`
: t('knowledge.sourceLater')}
</p>
<div className="flex shrink-0 items-center gap-2">
<Button variant="secondary" onClick={onClose} disabled={busy}>
{t('knowledge.cancel')}
</Button>
<Button onClick={() => void submit()} disabled={busy || nameMissing}>
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{submitLabel}
</Button>
</div>
</div>
}
>
<form
className="space-y-6"
onSubmit={(event) => {
event.preventDefault()
void submit()
}}
>
{variant === 'space' ? (
<section className="space-y-3">
<SectionLabel>{t('knowledge.identity')}</SectionLabel>
<LabeledInput
label={t('knowledge.name')}
required
autoFocus
placeholder={t('knowledge.namePlaceholder')}
value={name}
error={nameError}
onChange={(event) => {
setName(event.target.value)
if (error) setError(null)
}}
/>
<LabeledTextarea
label={t('knowledge.description')}
hint={t('knowledge.descriptionHint')}
placeholder={t('knowledge.descriptionPlaceholder')}
value={description}
onChange={(event) => setDescription(event.target.value)}
rows={2}
/>
</section>
) : null}
<section className="space-y-3">
<div className="flex items-center justify-between gap-2">
<SectionLabel>{t('knowledge.source')}</SectionLabel>
{sourceCount > 0 ? (
<span className="rounded-full bg-accent-soft px-2 py-0.5 text-[11px] font-medium text-accent">
{sourceCount} {t('knowledge.selected')}
</span>
) : (
<span className="text-[11px] text-ink-subtle">{t('knowledge.sourceOptional')}</span>
)}
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4" role="radiogroup" aria-label={t('knowledge.source')}>
{SOURCE_OPTIONS.map((option) => {
const Icon = option.icon
const active = kind === option.id
return (
<button
key={option.id}
type="button"
role="radio"
aria-checked={active}
disabled={busy}
onClick={() => {
setKind(option.id)
setError(null)
}}
className={`flex flex-col items-start gap-1.5 rounded-2xl border px-3 py-2.5 text-start transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ${
active
? 'border-accent bg-accent-soft shadow-[0_8px_18px_-14px_rgba(37,99,235,0.9)]'
: 'border-line bg-panel hover:border-line-strong hover:bg-surface-subtle'
}`}
>
<span
className={`inline-flex h-8 w-8 items-center justify-center rounded-xl ${
active ? 'bg-accent text-white' : 'bg-surface-subtle text-ink-muted'
}`}
>
<Icon className="h-4 w-4" />
</span>
<span className="text-sm font-medium text-ink">{t(option.labelKey)}</span>
<span className="hidden text-[11px] leading-4 text-ink-muted sm:block">{t(option.hintKey)}</span>
</button>
)
})}
</div>
{kind === 'file' ? (
<div
onDragOver={(event) => {
event.preventDefault()
setDragging(true)
}}
onDragLeave={() => setDragging(false)}
onDrop={(event) => {
event.preventDefault()
setDragging(false)
if (event.dataTransfer.files.length) takeFiles(event.dataTransfer.files)
}}
onClick={(event) => {
if ((event.target as HTMLElement).closest('[data-no-browse]')) return
fileInputRef.current?.click()
}}
className={`cursor-pointer rounded-2xl border border-dashed p-5 text-center transition ${
dragging ? 'border-accent bg-accent-soft' : 'border-line bg-surface-subtle/60 hover:border-line-strong'
}`}
>
<span className="mx-auto flex h-11 w-11 items-center justify-center rounded-2xl bg-panel text-accent shadow-sm ring-1 ring-line">
<Upload className="h-5 w-5" />
</span>
<p className="mt-3 text-sm font-medium text-ink">{t('knowledge.dropTitle')}</p>
<p className="mt-1 text-xs text-ink-muted">{t('knowledge.orBrowse')}</p>
<p className="mt-2 text-[11px] leading-5 text-ink-subtle">{t('knowledge.fileHint')}</p>
{uploaded.length ? (
<ul data-no-browse className="mt-4 space-y-2 text-start" onClick={(event) => event.stopPropagation()}>
{uploaded.map((file) => (
<li
key={file.id}
className="flex items-center gap-3 rounded-xl border border-line bg-panel px-3 py-2"
>
<FileText className="h-4 w-4 shrink-0 text-accent" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-ink">{file.title?.trim() || file.name}</p>
<p className="text-[11px] text-ink-muted">{fileKindLine(file.kind)}</p>
</div>
<button
type="button"
className="rounded-lg p-1 text-ink-muted hover:bg-danger-soft hover:text-danger"
aria-label={t('knowledge.removeFile')}
onClick={() => setUploaded((current) => current.filter((item) => item.id !== file.id))}
>
<X className="h-3.5 w-3.5" />
</button>
</li>
))}
</ul>
) : null}
</div>
) : null}
{kind === 'library' ? (
<div className="overflow-hidden rounded-2xl border border-line">
<div className="border-b border-line bg-surface-subtle px-3 py-2">
<Input
value={libraryQuery}
onChange={(event) => setLibraryQuery(event.target.value)}
placeholder={t('knowledge.librarySearch')}
leadingIcon={<Search className="h-4 w-4" />}
/>
</div>
<div className="max-h-52 overflow-y-auto p-2">
{filteredLibrary.length === 0 ? (
<p className="px-2 py-8 text-center text-xs text-ink-muted">{t('knowledge.libraryEmpty')}</p>
) : (
filteredLibrary.map((file) => {
const checked = selectedIds.includes(file.id)
return (
<label
key={file.id}
className={`mb-1 flex cursor-pointer items-center gap-3 rounded-xl px-2.5 py-2 last:mb-0 ${
checked ? 'bg-accent-soft' : 'hover:bg-surface-subtle'
}`}
>
<input
type="checkbox"
className="accent-accent"
checked={checked}
onChange={() =>
setSelectedIds((current) =>
checked ? current.filter((id) => id !== file.id) : [...current, file.id],
)
}
/>
<FileText className="h-4 w-4 shrink-0 text-ink-muted" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-ink">{file.title?.trim() || file.name}</p>
<p className="text-[11px] text-ink-muted">
{fileKindLine(file.kind)}
{file.size_bytes != null ? ` · ${formatBytes(file.size_bytes)}` : ''}
</p>
</div>
</label>
)
})
)}
</div>
{selectedFiles.length ? (
<p className="border-t border-line px-3 py-2 text-[11px] text-ink-muted">
{selectedFiles.map((file) => file.title?.trim() || file.name).join(' · ')}
</p>
) : null}
</div>
) : null}
{kind === 'url' ? (
<LabeledInput
label={t('knowledge.url')}
hint={t('knowledge.urlHint')}
value={url}
dir="ltr"
inputMode="url"
onChange={(event) => setUrl(event.target.value)}
placeholder="https://"
/>
) : null}
{kind === 'text' ? (
<div className="space-y-3">
<LabeledInput
label={t('knowledge.textTitle')}
value={textTitle}
onChange={(event) => setTextTitle(event.target.value)}
placeholder={t('knowledge.textTitlePlaceholder')}
/>
<LabeledTextarea
label={t('knowledge.text')}
value={text}
onChange={(event) => setText(event.target.value)}
rows={6}
placeholder={t('knowledge.textPlaceholder')}
/>
</div>
) : null}
</section>
<Collapsible className="rounded-2xl border border-line bg-surface-subtle/50">
<Collapsible.Trigger className="flex items-center justify-between gap-3 px-3 py-2.5 text-sm text-ink-muted hover:text-ink">
<span className="inline-flex items-center gap-2">
<Settings2 className="h-3.5 w-3.5" />
{t('knowledge.advanced')}
</span>
<span className="inline-flex items-center gap-2">
<span className="hidden text-[11px] text-ink-subtle sm:inline">
{strategy === 'Automatic' ? t('knowledge.strategyAuto') : t('knowledge.strategySize')}
{' · '}
{chunkSize}
</span>
<Collapsible.Arrow className="text-ink-subtle" />
</span>
</Collapsible.Trigger>
<Collapsible.Content className="border-t border-line px-3 py-3">
<div className="grid gap-3 sm:grid-cols-3">
<Field label={t('knowledge.strategy')} hint={t('knowledge.strategyHint')}>
<Select
ariaLabel={t('knowledge.strategy')}
value={strategy}
onChange={setStrategy}
options={[
{ value: 'Automatic', label: t('knowledge.strategyAuto') },
{ value: 'CHUNK_BY_SIZE', label: t('knowledge.strategySize') },
]}
/>
</Field>
<Field label={t('knowledge.chunkSize')}>
<Input
type="number"
min={64}
step={1}
inputMode="numeric"
value={chunkSize}
onChange={(event) => setChunkSize(event.target.value)}
/>
</Field>
<Field label={t('knowledge.chunkOverlap')}>
<Input
type="number"
min={0}
step={1}
inputMode="numeric"
value={chunkOverlap}
onChange={(event) => setChunkOverlap(event.target.value)}
/>
</Field>
</div>
</Collapsible.Content>
</Collapsible>
{formError ? <p className="text-sm text-danger">{formError}</p> : null}
</form>
</Modal>
<input
ref={fileInputRef}
type="file"
multiple
accept={KNOWLEDGE_FILE_ACCEPT}
className="hidden"
onChange={(event) => {
if (event.target.files?.length) takeFiles(event.target.files)
event.target.value = ''
}}
/>
<FileTitleModal
files={pendingUpload}
onClose={() => setPendingUpload(null)}
onConfirm={(files, titles) => void confirmUpload(files, titles)}
/>
</>
)
}
function SectionLabel({ children }: { children: string }) {
return <h4 className="text-[11px] font-semibold text-ink-muted">{children}</h4>
}