refactor: 将 A_Memorix 重构为主线长期记忆子系统并重建管理界面
- 将 A_Memorix 从旧 submodule / 插件形态迁入主线源码,主体落到 src/A_memorix - 调整主程序接入方式,使 A_Memorix 作为源码内长期记忆子系统运行 - 回收父项目插件体系中针对 A_Memorix 的特判,减少对 plugin 通用层的侵入 - 将长期记忆配置、运行时、自检、导入、调优等能力收口到 memory 路由与主线服务层 - 重做长期记忆控制台与图谱页面,按 MaiBot 现有 dashboard 风格接入 - 补充实体关系图与证据视图双视图能力,支持查看节点、关系、段落及其证据链路 - 新增长期记忆配置编辑器与 memory-api,支持主线内配置管理 - 补齐删除管理能力:删除预览、混合删除、来源批量删除、删除操作恢复 - 优化删除预览与删除操作详情的前端展示,支持分页、检索,并以实体名/关系内容/段落摘要替代单纯 hash 展示 - 修复图谱与控制台相关前端问题,包括证据视图切换、查询触发时机、删除弹层空值保护等 - 新增或更新 A_Memorix 相关测试、WebUI 路由测试、前端 vitest 测试与辅助验证脚本 - 移除旧 plugins/A_memorix、.gitmodules 及相关历史维护文档
This commit is contained in:
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import CodeMirror from '@uiw/react-codemirror'
|
||||
import { css } from '@codemirror/lang-css'
|
||||
import { json, jsonParseLinter } from '@codemirror/lang-json'
|
||||
import { linter } from '@codemirror/lint'
|
||||
import { python } from '@codemirror/lang-python'
|
||||
import { oneDark } from '@codemirror/theme-one-dark'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
@@ -29,7 +30,7 @@ interface CodeEditorProps {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const languageExtensions: Record<Language, any[]> = {
|
||||
python: [python()],
|
||||
json: [json(), jsonParseLinter()],
|
||||
json: [json(), linter(jsonParseLinter())],
|
||||
toml: [StreamLanguage.define(tomlMode)],
|
||||
css: [css()],
|
||||
text: [],
|
||||
|
||||
311
dashboard/src/components/memory/MemoryConfigEditor.tsx
Normal file
311
dashboard/src/components/memory/MemoryConfigEditor.tsx
Normal file
@@ -0,0 +1,311 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { ListFieldEditor } from '@/components'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import type { ConfigFieldSchema, PluginConfigSchema } from '@/lib/plugin-api'
|
||||
|
||||
interface MemoryConfigEditorProps {
|
||||
schema: PluginConfigSchema
|
||||
config: Record<string, unknown>
|
||||
onChange: (nextConfig: Record<string, unknown>) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
function getNestedRecord(config: Record<string, unknown>, path: string): Record<string, unknown> | undefined {
|
||||
const parts = path.split('.').filter(Boolean)
|
||||
let current: unknown = config
|
||||
|
||||
for (const part of parts) {
|
||||
if (!current || typeof current !== 'object' || Array.isArray(current)) {
|
||||
return undefined
|
||||
}
|
||||
current = (current as Record<string, unknown>)[part]
|
||||
}
|
||||
|
||||
if (!current || typeof current !== 'object' || Array.isArray(current)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return current as Record<string, unknown>
|
||||
}
|
||||
|
||||
function setNestedField(
|
||||
config: Record<string, unknown>,
|
||||
path: string,
|
||||
fieldName: string,
|
||||
value: unknown,
|
||||
): Record<string, unknown> {
|
||||
const parts = path.split('.').filter(Boolean)
|
||||
const nextConfig: Record<string, unknown> = { ...config }
|
||||
let target = nextConfig
|
||||
let source: Record<string, unknown> | undefined = config
|
||||
|
||||
for (const part of parts) {
|
||||
const sourceValue: unknown = source?.[part]
|
||||
const nextValue =
|
||||
sourceValue && typeof sourceValue === 'object' && !Array.isArray(sourceValue)
|
||||
? { ...(sourceValue as Record<string, unknown>) }
|
||||
: {}
|
||||
target[part] = nextValue
|
||||
target = nextValue
|
||||
source =
|
||||
sourceValue && typeof sourceValue === 'object' && !Array.isArray(sourceValue)
|
||||
? (sourceValue as Record<string, unknown>)
|
||||
: undefined
|
||||
}
|
||||
|
||||
target[fieldName] = value
|
||||
return nextConfig
|
||||
}
|
||||
|
||||
function FieldRenderer({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
field: ConfigFieldSchema
|
||||
value: unknown
|
||||
onChange: (value: unknown) => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const [jsonDraft, setJsonDraft] = useState(
|
||||
typeof value === 'string' ? String(value) : JSON.stringify(value ?? field.default ?? {}, null, 2),
|
||||
)
|
||||
|
||||
switch (field.ui_type) {
|
||||
case 'switch':
|
||||
return (
|
||||
<div className="flex items-center justify-between rounded-lg border bg-background px-4 py-3">
|
||||
<div className="space-y-1">
|
||||
<Label>{field.label}</Label>
|
||||
{field.hint && <p className="text-xs text-muted-foreground">{field.hint}</p>}
|
||||
</div>
|
||||
<Switch
|
||||
checked={Boolean(value ?? field.default)}
|
||||
onCheckedChange={onChange}
|
||||
disabled={disabled || field.disabled}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'number':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>{field.label}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={String(value ?? field.default ?? '')}
|
||||
onChange={(event) => onChange(Number(event.target.value))}
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
step={field.step ?? 1}
|
||||
disabled={disabled || field.disabled}
|
||||
placeholder={field.placeholder}
|
||||
/>
|
||||
{field.hint && <p className="text-xs text-muted-foreground">{field.hint}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'select':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>{field.label}</Label>
|
||||
<Select
|
||||
value={String(value ?? field.default ?? '')}
|
||||
onValueChange={onChange}
|
||||
disabled={disabled || field.disabled}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={field.placeholder ?? '请选择'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(field.choices ?? []).map((choice) => (
|
||||
<SelectItem key={String(choice)} value={String(choice)}>
|
||||
{String(choice)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{field.hint && <p className="text-xs text-muted-foreground">{field.hint}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'textarea':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>{field.label}</Label>
|
||||
<Textarea
|
||||
value={String(value ?? field.default ?? '')}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
rows={field.rows ?? 4}
|
||||
placeholder={field.placeholder}
|
||||
disabled={disabled || field.disabled}
|
||||
/>
|
||||
{field.hint && <p className="text-xs text-muted-foreground">{field.hint}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'list':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>{field.label}</Label>
|
||||
<ListFieldEditor
|
||||
value={Array.isArray(value) ? value : (Array.isArray(field.default) ? field.default : [])}
|
||||
onChange={onChange as (value: unknown[]) => void}
|
||||
itemType={field.item_type}
|
||||
itemFields={field.item_fields}
|
||||
minItems={field.min_items}
|
||||
maxItems={field.max_items}
|
||||
placeholder={field.placeholder}
|
||||
disabled={disabled || field.disabled}
|
||||
/>
|
||||
{field.hint && <p className="text-xs text-muted-foreground">{field.hint}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'json':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>{field.label}</Label>
|
||||
<Textarea
|
||||
value={jsonDraft}
|
||||
rows={field.rows ?? 6}
|
||||
disabled={disabled || field.disabled}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value
|
||||
setJsonDraft(nextValue)
|
||||
try {
|
||||
onChange(JSON.parse(nextValue))
|
||||
} catch {
|
||||
// keep draft until valid JSON
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{field.hint && <p className="text-xs text-muted-foreground">{field.hint}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>{field.label}</Label>
|
||||
<Input
|
||||
value={String(value ?? field.default ?? '')}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
disabled={disabled || field.disabled}
|
||||
placeholder={field.placeholder}
|
||||
/>
|
||||
{field.hint && <p className="text-xs text-muted-foreground">{field.hint}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function SectionCard({
|
||||
sectionName,
|
||||
schema,
|
||||
config,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
sectionName: string
|
||||
schema: PluginConfigSchema
|
||||
config: Record<string, unknown>
|
||||
onChange: (nextConfig: Record<string, unknown>) => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
const section = schema.sections[sectionName]
|
||||
if (!section) {
|
||||
return null
|
||||
}
|
||||
|
||||
const sectionValues = getNestedRecord(config, sectionName) ?? {}
|
||||
const orderedFields = Object.values(section.fields).sort((left, right) => left.order - right.order)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{section.title}</CardTitle>
|
||||
{section.description && <CardDescription>{section.description}</CardDescription>}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{orderedFields.map((field) => (
|
||||
<FieldRenderer
|
||||
key={`${sectionName}.${field.name}`}
|
||||
field={field}
|
||||
value={sectionValues[field.name]}
|
||||
disabled={disabled}
|
||||
onChange={(value) => onChange(setNestedField(config, sectionName, field.name, value))}
|
||||
/>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function MemoryConfigEditor({ schema, config, onChange, disabled }: MemoryConfigEditorProps) {
|
||||
const tabs = useMemo(
|
||||
() => [...(schema.layout.tabs ?? [])].sort((left, right) => left.order - right.order),
|
||||
[schema.layout.tabs],
|
||||
)
|
||||
|
||||
if (tabs.length === 0) {
|
||||
const orderedSections = Object.keys(schema.sections).sort(
|
||||
(left, right) => (schema.sections[left]?.order ?? 0) - (schema.sections[right]?.order ?? 0),
|
||||
)
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{orderedSections.map((sectionName) => (
|
||||
<SectionCard
|
||||
key={sectionName}
|
||||
sectionName={sectionName}
|
||||
schema={schema}
|
||||
config={config}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs defaultValue={tabs[0]?.id} className="space-y-4">
|
||||
<TabsList className="h-auto flex-wrap justify-start">
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger key={tab.id} value={tab.id}>
|
||||
{tab.title}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{tabs.map((tab) => (
|
||||
<TabsContent key={tab.id} value={tab.id} className="space-y-4">
|
||||
{tab.sections.map((sectionName) => (
|
||||
<SectionCard
|
||||
key={sectionName}
|
||||
sectionName={sectionName}
|
||||
schema={schema}
|
||||
config={config}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
281
dashboard/src/components/memory/MemoryDeleteDialog.tsx
Normal file
281
dashboard/src/components/memory/MemoryDeleteDialog.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { AlertTriangle, RotateCcw, Search, Trash2 } from 'lucide-react'
|
||||
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import type {
|
||||
MemoryDeleteExecutePayload,
|
||||
MemoryDeletePreviewItemPayload,
|
||||
MemoryDeletePreviewPayload,
|
||||
} from '@/lib/memory-api'
|
||||
|
||||
const DELETE_PREVIEW_PAGE_SIZE = 8
|
||||
|
||||
function formatMode(mode: string): string {
|
||||
switch (mode) {
|
||||
case 'entity':
|
||||
return '实体删除'
|
||||
case 'relation':
|
||||
return '关系删除'
|
||||
case 'paragraph':
|
||||
return '段落删除'
|
||||
case 'source':
|
||||
return '来源删除'
|
||||
case 'mixed':
|
||||
return '混合删除'
|
||||
default:
|
||||
return mode || '删除'
|
||||
}
|
||||
}
|
||||
|
||||
function formatCountLabel(label: string, value: number): string {
|
||||
return `${label} ${value}`
|
||||
}
|
||||
|
||||
function PreviewItemList({ items }: { items: MemoryDeletePreviewItemPayload[] }) {
|
||||
if (items.length <= 0) {
|
||||
return <p className="text-sm text-muted-foreground">当前预览没有可展示的明细项。</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{items.slice(0, 16).map((item) => (
|
||||
<div key={`${item.item_type}:${item.item_hash}:${item.item_key ?? ''}`} className="rounded-lg border bg-muted/30 p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="outline">{item.item_type}</Badge>
|
||||
{item.source ? <Badge variant="secondary">{item.source}</Badge> : null}
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-medium break-words">{item.label || item.item_key || item.item_hash}</div>
|
||||
{item.preview ? <div className="mt-1 text-xs text-muted-foreground break-words">{item.preview}</div> : null}
|
||||
<code className="mt-2 block break-all text-[11px] text-muted-foreground">{item.item_hash || item.item_key}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface MemoryDeleteDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
title: string
|
||||
description?: string
|
||||
preview: MemoryDeletePreviewPayload | null
|
||||
result: MemoryDeleteExecutePayload | null
|
||||
loadingPreview?: boolean
|
||||
executing?: boolean
|
||||
restoring?: boolean
|
||||
error?: string | null
|
||||
onExecute: () => void
|
||||
onRestore?: () => void
|
||||
}
|
||||
|
||||
export function MemoryDeleteDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
preview,
|
||||
result,
|
||||
loadingPreview = false,
|
||||
executing = false,
|
||||
restoring = false,
|
||||
error,
|
||||
onExecute,
|
||||
onRestore,
|
||||
}: MemoryDeleteDialogProps) {
|
||||
const [itemSearch, setItemSearch] = useState('')
|
||||
const [itemPage, setItemPage] = useState(1)
|
||||
const counts = preview?.counts ?? result?.counts ?? {}
|
||||
const previewSources = Array.isArray(preview?.sources) ? preview.sources : []
|
||||
const previewItems = Array.isArray(preview?.items) ? preview.items : []
|
||||
const filteredPreviewItems = useMemo(() => {
|
||||
const keyword = itemSearch.trim().toLowerCase()
|
||||
if (!keyword) {
|
||||
return previewItems
|
||||
}
|
||||
return previewItems.filter((item) =>
|
||||
[
|
||||
item.item_type,
|
||||
item.item_hash,
|
||||
item.item_key,
|
||||
item.label,
|
||||
item.preview,
|
||||
item.source,
|
||||
]
|
||||
.map((value) => String(value ?? '').toLowerCase())
|
||||
.some((value) => value.includes(keyword)),
|
||||
)
|
||||
}, [itemSearch, previewItems])
|
||||
const itemPageCount = Math.max(1, Math.ceil(filteredPreviewItems.length / DELETE_PREVIEW_PAGE_SIZE))
|
||||
const pagedPreviewItems = useMemo(() => {
|
||||
const start = (itemPage - 1) * DELETE_PREVIEW_PAGE_SIZE
|
||||
return filteredPreviewItems.slice(start, start + DELETE_PREVIEW_PAGE_SIZE)
|
||||
}, [filteredPreviewItems, itemPage])
|
||||
const countBadges = [
|
||||
{ key: 'entities', label: '实体', value: Number(counts.entities ?? 0) },
|
||||
{ key: 'relations', label: '关系', value: Number(counts.relations ?? 0) },
|
||||
{ key: 'paragraphs', label: '段落', value: Number(counts.paragraphs ?? 0) },
|
||||
{ key: 'sources', label: '来源', value: Number(counts.sources ?? 0) },
|
||||
].filter((item) => item.value > 0)
|
||||
|
||||
useEffect(() => {
|
||||
setItemSearch('')
|
||||
setItemPage(1)
|
||||
}, [preview?.mode, preview?.item_count, open])
|
||||
|
||||
useEffect(() => {
|
||||
setItemPage(1)
|
||||
}, [itemSearch])
|
||||
|
||||
useEffect(() => {
|
||||
if (itemPage > itemPageCount) {
|
||||
setItemPage(itemPageCount)
|
||||
}
|
||||
}, [itemPage, itemPageCount])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[85vh] grid grid-rows-[auto_1fr_auto]" confirmOnEnter>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-500" />
|
||||
{title}
|
||||
</DialogTitle>
|
||||
{description ? <DialogDescription>{description}</DialogDescription> : null}
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody className="space-y-4 overflow-y-auto">
|
||||
{loadingPreview ? (
|
||||
<div className="rounded-lg border bg-muted/30 p-4 text-sm text-muted-foreground">正在生成删除预览...</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{preview ? (
|
||||
<>
|
||||
<div className="rounded-xl border bg-muted/30 p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge>{formatMode(preview.mode)}</Badge>
|
||||
<Badge variant="secondary">{formatCountLabel('预览项', Number(preview.item_count ?? previewItems.length))}</Badge>
|
||||
{countBadges.map((item) => (
|
||||
<Badge key={item.key} variant="outline">
|
||||
{formatCountLabel(item.label, item.value)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
{previewSources.length > 0 ? (
|
||||
<div className="mt-3 text-sm text-muted-foreground break-words">
|
||||
关联来源:{previewSources.join('、')}
|
||||
</div>
|
||||
) : null}
|
||||
{preview.matched_source_count ? (
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
命中来源 {preview.matched_source_count}
|
||||
{preview.requested_source_count ? ` / 请求来源 ${preview.requested_source_count}` : ''}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-semibold">本次将删除的对象摘要</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
命中 {filteredPreviewItems.length} / {previewItems.length} 项
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 md:min-w-[300px]">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={itemSearch}
|
||||
onChange={(event) => setItemSearch(event.target.value)}
|
||||
placeholder="搜索类型 / hash / item_key / source"
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>第 {itemPage} / {itemPageCount} 页</span>
|
||||
<span>每页 {DELETE_PREVIEW_PAGE_SIZE} 项</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="h-[320px] rounded-lg border bg-background/60">
|
||||
<div className="p-3">
|
||||
<PreviewItemList items={pagedPreviewItems} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setItemPage((current) => Math.max(1, current - 1))}
|
||||
disabled={itemPage <= 1}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
支持按对象类型、hash、item_key、source 和预览内容检索
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setItemPage((current) => Math.min(itemPageCount, current + 1))}
|
||||
disabled={itemPage >= itemPageCount}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{result?.success ? (
|
||||
<Alert>
|
||||
<AlertDescription className="space-y-1">
|
||||
<div>删除执行成功,操作 ID:<code>{result.operation_id}</code></div>
|
||||
<div>
|
||||
实际删除:实体 {result.deleted_entity_count},关系 {result.deleted_relation_count},段落 {result.deleted_paragraph_count},来源 {result.deleted_source_count}
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
关闭
|
||||
</Button>
|
||||
{result?.success && onRestore ? (
|
||||
<Button variant="outline" onClick={onRestore} disabled={restoring}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
{restoring ? '恢复中...' : '恢复本次删除'}
|
||||
</Button>
|
||||
) : null}
|
||||
{!result?.success ? (
|
||||
<Button data-dialog-action="confirm" variant="destructive" onClick={onExecute} disabled={loadingPreview || executing || !preview}>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{executing ? '执行中...' : '确认删除'}
|
||||
</Button>
|
||||
) : null}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user