520 lines
20 KiB
TypeScript
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>
|
|
}
|